From 142e07c502dfbb274892cd5aa640ade31264183d Mon Sep 17 00:00:00 2001 From: Jason Rayne Date: Fri, 13 Jun 2025 16:11:46 -0700 Subject: [PATCH 01/94] 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/94] 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/94] 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/94] 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/94] 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/94] 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/94] 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/94] 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/94] 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/94] 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/94] 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/94] 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/94] 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/94] 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/94] 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/94] 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/94] 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/94] 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/94] 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/94] 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/94] 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/94] 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/94] 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/94] 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/94] 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/94] 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/94] 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/94] 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/94] 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/94] 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/94] 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/94] 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/94] 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/94] 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/94] 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/94] 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/94] 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/94] 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/94] 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/94] 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/94] 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/94] 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/94] 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/94] 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/94] 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/94] 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/94] 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/94] 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/94] 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/94] 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/94] 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/94] 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").?); } } From b915084c38be8ccd7f8de62ce1da1852b2b24dad Mon Sep 17 00:00:00 2001 From: Qwerasd Date: Wed, 9 Jul 2025 10:28:35 -0600 Subject: [PATCH 53/94] font/coretext: don't use vertical overlap constraints --- src/font/face/coretext.zig | 9 ++++++++- 1 file changed, 8 insertions(+), 1 deletion(-) diff --git a/src/font/face/coretext.zig b/src/font/face/coretext.zig index e2d60905d..4e6804eb0 100644 --- a/src/font/face/coretext.zig +++ b/src/font/face/coretext.zig @@ -343,7 +343,14 @@ pub const Face = struct { const cell_width: f64 = @floatFromInt(metrics.cell_width); // const cell_height: f64 = @floatFromInt(metrics.cell_height); - const glyph_size = opts.constraint.constrain( + // We eliminate any negative vertical padding since these overlap + // values aren't needed under CoreText with how precisely we apply + // constraints, and they can lead to extra height that looks bad + // for things like powerline glyphs. + var constraint = opts.constraint; + constraint.pad_top = @max(0.0, constraint.pad_top); + constraint.pad_bottom = @max(0.0, constraint.pad_bottom); + const glyph_size = constraint.constrain( .{ .width = rect.size.width, .height = rect.size.height, From e68c1d2cad0d0ef685f2b550873bfc471ff2b2f6 Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Wed, 9 Jul 2025 09:30:20 -0700 Subject: [PATCH 54/94] config: add available since for SSH shell integration --- src/config/Config.zig | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/src/config/Config.zig b/src/config/Config.zig index 4e2f2d84b..5ebb5561b 100644 --- a/src/config/Config.zig +++ b/src/config/Config.zig @@ -689,10 +689,10 @@ palette: Palette = .{}, /// other colors at runtime: /// /// * `cell-foreground` - Match the cell foreground color. -/// (Available since version 1.2.0) +/// (Available since: 1.2.0) /// /// * `cell-background` - Match the cell background color. -/// (Available since version 1.2.0) +/// (Available since: 1.2.0) @"cursor-color": ?TerminalColor = null, /// The opacity level (opposite of transparency) of the cursor. A value of 1 @@ -2217,6 +2217,7 @@ keybind: Keybinds = .{}, /// 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. +/// (Available since: 1.2.0) /// /// * `ssh-terminfo` - Enable automatic terminfo installation on remote hosts. /// Attempts to install Ghostty's terminfo entry using `infocmp` and `tic` when @@ -2225,6 +2226,7 @@ keybind: Keybinds = .{}, /// 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. +/// (Available since: 1.2.0) /// /// 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 579b15bef77140bdf6530fe823a4f8a9cc602427 Mon Sep 17 00:00:00 2001 From: Qwerasd Date: Wed, 9 Jul 2025 10:32:45 -0600 Subject: [PATCH 55/94] font/coretext: rework glyph quantization math The old math didn't allow fractional pixels on the left and bottom, and stretched glyphs vertically since the height was always rounded up. At very small font sizes this looked good, but at medium and even large sizes this just made things inconsistent and janky. These new calculations are practically pixel-identical to whatever CoreText is doing in 99% of cases, and the remaining cases seem to be some sort of auto-hinting since it's internal features of the glyph getting repositioned. Over all, I still prefer this to CoreText's quantize option, but if this causes further issues we should probably just revert the whole thing and go ahead and add an extra pixel of padding to the bottom and left... --- src/font/face/coretext.zig | 116 ++++++++++++++++++++++--------------- 1 file changed, 68 insertions(+), 48 deletions(-) diff --git a/src/font/face/coretext.zig b/src/font/face/coretext.zig index 4e6804eb0..bb9a472d2 100644 --- a/src/font/face/coretext.zig +++ b/src/font/face/coretext.zig @@ -361,48 +361,75 @@ pub const Face = struct { opts.constraint_width, ); - // We manually quantize the position and size of the glyph to whole - // pixel boundaries. Since macOS doesn't do font hinting this helps - // a lot for legibility at small sizes on low dpi displays. + // These calculations are an attempt to mostly imitate the effect of + // `shouldSubpixelQuantizeFonts`[^1], which helps maximize legibility + // at small pixel sizes (low DPI). We do this math ourselves instead + // of letting CoreText do it because it's not entirely clear how the + // math in CoreText works and we've run in to edge cases where glyphs + // have their bottom or left row cut off due to bad rounding. // - // Well, okay, so, it seems like macOS does have a rudimentary auto- - // hinter of sorts, except they call it "subpixel quantization"[^1]. + // This math seems to have a mostly comparable result to whatever it + // is that CoreText does, and is even (in my opinion) better in some + // cases. // - // Why not just use that? Because it's unpredictable and would force - // us to have an extra pixel of padding in the atlas for most glyphs - // that don't need it, since it's hard to know whether a given glyph - // will have its bottom or left edge snapped out an extra pixel. + // I'm not entirely certain but I suspect that when you enable the + // CoreText option it also does some sort of rudimentary hinting, + // but it doesn't seem to make that big of a difference in terms + // of legibility in the end. // - // Also, this empirically just looks a whole lot better than theirs. - // Admittedly this is a very specific use case, we're rendering for - // a monospace grid and don't really have to worry about sub-pixel - // positioning; I'm sure Apple's technique is better for cases with - // proportional text. - // - // An effort was made to more or less match Apple's quantization in - // terms of resulting whole-pixel glyph sizes. Oddly it looks like - // Apple is still horizontally quantizing to thirds of a pixel, as - // if they're doing subpixel rendering for a horizontally striped - // LCD, even though they haven't done subpixel rendering for years. - // We don't match them on that, it tends to just make it blurrier. - // - // [^1]: Well I'm 80% sure it's hinting since it seems to account for - // features inside of the glyph like crossbars, not just the bounding - // box like we do. The documentation is... sparse. Ref: - // https://developer.apple.com/documentation/coregraphics/cgcontext/setshouldsubpixelquantizefonts(_:)?language=objc + // [^1]: https://developer.apple.com/documentation/coregraphics/cgcontext/setshouldsubpixelquantizefonts(_:)?language=objc + + // We only want to apply quantization if we don't have any + // constraints and this isn't a bitmap glyph, since CoreText + // doesn't seem to apply its quantization to bitmap glyphs. // // TODO: Maybe gate this so it only applies at small font sizes, // or else offer a user config option that can disable it. - const x = @round(glyph_size.x); - const y = @round(glyph_size.y); - // We subtract a third here so that we behave (somewhat) like the weird - // one third pixel quantization that Apple does. This is basically just - // a fudge factor though. - const width = @max(1.0, @ceil(glyph_size.width + glyph_size.x - x - 1.0 / 3.0)); - const height = @max(1.0, @ceil(glyph_size.height + glyph_size.y - y)); + const should_quantize = !sbix and std.meta.eql(opts.constraint, .none); - const px_width: u32 = @intFromFloat(@ceil(width)); - const px_height: u32 = @intFromFloat(@ceil(height)); + // We offset our glyph by its bearings when we draw it, using `@floor` + // here rounds it *up* since we negate it right outside. Moving it by + // whole pixels ensures that we don't disturb the pixel alignment of + // the glyph, fractional pixels will still be drawn on all sides as + // necessary. + const draw_x = -@floor(rect.origin.x); + const draw_y = -@floor(rect.origin.y); + + // We use `x` and `y` for our full pixel bearings post-raster. + // We need to subtract the fractional pixel of difference from + // the edge of the draw area to the edge of the actual glyph. + const frac_x = rect.origin.x + draw_x; + const frac_y = rect.origin.y + draw_y; + const x = glyph_size.x - frac_x; + const y = glyph_size.y - frac_y; + + // We never modify the width. + // + // When using the CoreText option the widths do seem to be + // modified extremely subtly, but even at very small font + // sizes it's hardly a noticeable difference. + const width = glyph_size.width; + + // If the top of the glyph (taking in to account the y position) + // is within half a pixel of an exact pixel edge, we round up the + // height, otherwise leave it alone. + // + // This seems to match what CoreText does. + const frac_top = (glyph_size.height + frac_y) - @floor(glyph_size.height + frac_y); + const height = + if (should_quantize) + if (frac_top >= 0.5) + glyph_size.height + 1 - frac_top + else + glyph_size.height + else + glyph_size.height; + + // Add the fractional pixel to the width and height and take + // the ceiling to get a canvas size that will definitely fit + // our drawn glyph. + const px_width: u32 = @intFromFloat(@ceil(width + frac_x)); + const px_height: u32 = @intFromFloat(@ceil(height + frac_y)); // Settings that are specific to if we are rendering text or emoji. const color: struct { @@ -512,13 +539,8 @@ pub const Face = struct { height / rect.size.height, ); - // We want to render the glyphs at (0,0), but the glyphs themselves - // are offset by bearings, so we have to undo those bearings in order - // to get them to 0,0. - self.font.drawGlyphs(&glyphs, &.{.{ - .x = -rect.origin.x, - .y = -rect.origin.y, - }}, ctx); + // Draw our glyph. + self.font.drawGlyphs(&glyphs, &.{.{ .x = draw_x, .y = draw_y }}, ctx); // Write our rasterized glyph to the atlas. const region = try atlas.reserve(alloc, px_width, px_height); @@ -526,7 +548,7 @@ pub const Face = struct { // This should be the distance from the bottom of // the cell to the top of the glyph's bounding box. - const offset_y: i32 = @as(i32, @intFromFloat(@ceil(y + height))); + const offset_y: i32 = @as(i32, @intFromFloat(@round(y))) + @as(i32, @intCast(px_height)); // This should be the distance from the left of // the cell to the left of the glyph's bounding box. @@ -545,9 +567,7 @@ pub const Face = struct { // since in that case the position was already calculated with the // new cell width in mind. if (opts.constraint.align_horizontal == .none) { - var advances: [glyphs.len]macos.graphics.Size = undefined; - _ = self.font.getAdvancesForGlyphs(.horizontal, &glyphs, &advances); - const advance = advances[0].width; + const advance = self.font.getAdvancesForGlyphs(.horizontal, &glyphs, null); const new_advance = cell_width * @as(f64, @floatFromInt(opts.cell_width orelse 1)); // If the original advance is greater than the cell width then @@ -559,13 +579,13 @@ pub const Face = struct { // We also don't want to do anything if the advance is zero or // less, since this is used for stuff like combining characters. if (advance > new_advance or advance <= 0.0) { - break :offset_x @intFromFloat(@ceil(x)); + break :offset_x @intFromFloat(@round(x)); } break :offset_x @intFromFloat( @round(x + (new_advance - advance) / 2), ); } else { - break :offset_x @intFromFloat(@ceil(x)); + break :offset_x @intFromFloat(@round(x)); } }; From d8e7a6634ea25a54ebe7e5c2bfe312f5a50b0513 Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Wed, 9 Jul 2025 10:22:44 -0700 Subject: [PATCH 56/94] build: temporarily disable stderr capture on distcheck --- src/build/GhosttyDist.zig | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/src/build/GhosttyDist.zig b/src/build/GhosttyDist.zig index 3d7ba3b8d..25ec7182b 100644 --- a/src/build/GhosttyDist.zig +++ b/src/build/GhosttyDist.zig @@ -115,7 +115,8 @@ pub fn init(b: *std.Build, cfg: *const Config) !GhosttyDist { // Capture stderr so it doesn't spew into the parent build. // On the flip side, if the test fails we won't know why so // that sucks but we should have already ran tests at this point. - _ = step.captureStdErr(); + // NOTE(mitchellh): temporarily disabled to diagnose heisenbug + //_ = step.captureStdErr(); break :step step; }; From 86dbfb98d72726d5cbf918d578e119cdff08e854 Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Wed, 9 Jul 2025 10:42:13 -0700 Subject: [PATCH 57/94] Run GTK unit tests in CI, fix broken tests We only ran `apprt=none` tests in CI, which misses a huge surface area of unit tests. Thankfully only a couple were broken. This fixes that. --- .github/workflows/test.yml | 11 ++++++++++- src/apprt/gtk/gtk_version.zig | 22 +++++++++++++++++++++- src/os/kernel_info.zig | 2 +- src/os/main.zig | 10 ++++++++-- 4 files changed, 40 insertions(+), 5 deletions(-) diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 8af7140c6..834d49a5c 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -561,7 +561,16 @@ jobs: name: ghostty authToken: "${{ secrets.CACHIX_AUTH_TOKEN }}" - - name: Test GTK Build + - name: Test + run: | + nix develop -c \ + zig build \ + -Dapp-runtime=gtk \ + -Dgtk-x11=${{ matrix.x11 }} \ + -Dgtk-wayland=${{ matrix.wayland }} \ + test + + - name: Build run: | nix develop -c \ zig build \ diff --git a/src/apprt/gtk/gtk_version.zig b/src/apprt/gtk/gtk_version.zig index 5d75fb4fe..6f3d733a5 100644 --- a/src/apprt/gtk/gtk_version.zig +++ b/src/apprt/gtk/gtk_version.zig @@ -103,7 +103,7 @@ pub inline fn runtimeUntil( test "atLeast" { const testing = std.testing; - const funs = &.{ atLeast, runtimeAtLeast, runtimeUntil }; + const funs = &.{ atLeast, runtimeAtLeast }; inline for (funs) |fun| { try testing.expect(fun(c.GTK_MAJOR_VERSION, c.GTK_MINOR_VERSION, c.GTK_MICRO_VERSION)); @@ -118,3 +118,23 @@ test "atLeast" { try testing.expect(fun(c.GTK_MAJOR_VERSION, c.GTK_MINOR_VERSION - 1, c.GTK_MICRO_VERSION + 1)); } } + +test "runtimeUntil" { + const testing = std.testing; + + // This is an array in case we add a comptime variant. + const funs = &.{runtimeUntil}; + inline for (funs) |fun| { + try testing.expect(!fun(c.GTK_MAJOR_VERSION, c.GTK_MINOR_VERSION, c.GTK_MICRO_VERSION)); + + try testing.expect(fun(c.GTK_MAJOR_VERSION, c.GTK_MINOR_VERSION, c.GTK_MICRO_VERSION + 1)); + try testing.expect(fun(c.GTK_MAJOR_VERSION, c.GTK_MINOR_VERSION + 1, c.GTK_MICRO_VERSION)); + try testing.expect(fun(c.GTK_MAJOR_VERSION + 1, c.GTK_MINOR_VERSION, c.GTK_MICRO_VERSION)); + + try testing.expect(!fun(c.GTK_MAJOR_VERSION - 1, c.GTK_MINOR_VERSION, c.GTK_MICRO_VERSION)); + try testing.expect(!fun(c.GTK_MAJOR_VERSION - 1, c.GTK_MINOR_VERSION + 1, c.GTK_MICRO_VERSION)); + try testing.expect(!fun(c.GTK_MAJOR_VERSION - 1, c.GTK_MINOR_VERSION, c.GTK_MICRO_VERSION + 1)); + + try testing.expect(!fun(c.GTK_MAJOR_VERSION, c.GTK_MINOR_VERSION - 1, c.GTK_MICRO_VERSION + 1)); + } +} diff --git a/src/os/kernel_info.zig b/src/os/kernel_info.zig index 9e3933dde..e57cc3047 100644 --- a/src/os/kernel_info.zig +++ b/src/os/kernel_info.zig @@ -17,7 +17,7 @@ test "read /proc/sys/kernel/osrelease" { if (comptime builtin.os.tag != .linux) return null; const allocator = std.testing.allocator; - const kernel_info = try getKernelInfo(allocator); + const kernel_info = getKernelInfo(allocator).?; defer allocator.free(kernel_info); // Since we can't hardcode the info in tests, just check diff --git a/src/os/main.zig b/src/os/main.zig index 7398fc779..41dc6aa29 100644 --- a/src/os/main.zig +++ b/src/os/main.zig @@ -2,6 +2,8 @@ //! system. These aren't restricted to syscalls or low-level operations, but //! also OS-specific features and conventions. +const builtin = @import("builtin"); + const dbus = @import("dbus.zig"); const desktop = @import("desktop.zig"); const env = @import("env.zig"); @@ -14,7 +16,7 @@ const openpkg = @import("open.zig"); const pipepkg = @import("pipe.zig"); const resourcesdir = @import("resourcesdir.zig"); const systemd = @import("systemd.zig"); -const kernelInfo = @import("kernel_info.zig"); +const kernel_info = @import("kernel_info.zig"); // Namespaces pub const args = @import("args.zig"); @@ -59,8 +61,12 @@ pub const pipe = pipepkg.pipe; pub const resourcesDir = resourcesdir.resourcesDir; pub const ResourcesDir = resourcesdir.ResourcesDir; pub const ShellEscapeWriter = shell.ShellEscapeWriter; -pub const getKernelInfo = kernelInfo.getKernelInfo; +pub const getKernelInfo = kernel_info.getKernelInfo; test { _ = i18n; + + if (comptime builtin.os.tag == .linux) { + _ = kernel_info; + } } From e18f16d94de50e4d82ff8a9c83b413374be10abb Mon Sep 17 00:00:00 2001 From: "Jeffrey C. Ollie" Date: Fri, 4 Jul 2025 14:57:09 -0500 Subject: [PATCH 58/94] linux: add functions for notifying systemd about process state Functions for notifying systemd that we are ready or have started reloading the configuration. --- src/os/systemd.zig | 133 +++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 133 insertions(+) diff --git a/src/os/systemd.zig b/src/os/systemd.zig index 9b67296d6..1a9a10ee7 100644 --- a/src/os/systemd.zig +++ b/src/os/systemd.zig @@ -63,3 +63,136 @@ pub fn launchedBySystemd() bool { else => false, }; } + +/// systemd notifications. Used by Ghostty to inform systemd of the state of the +/// process. Currently only used to notify systemd that we are ready and that +/// configuration reloading has started. +/// +/// See: https://www.freedesktop.org/software/systemd/man/latest/sd_notify.html +/// +/// These functions were re-implemented in Zig instead of using the `libsystemd` +/// library to avoid the complexity of another external dependency, as well as +/// to take advantage of Zig features like `comptime` to ensure minimal impact +/// on non-Linux systems (like FreeBSD) that will never support `systemd`. +/// +/// Linux systems that do not use `systemd` should not be impacted as they +/// should never start Ghostty with the `NOTIFY_SOCKET` environment variable set +/// and these functions essentially become a no-op. +/// +/// See `systemd`'s [Interface Portability and Stability Promise](https://systemd.io/PORTABILITY_AND_STABILITY/) +/// for assurances that the interfaces used here will be supported and stable for +/// the long term. +pub const notify = struct { + /// Send the given message to the UNIX socket specified in the NOTIFY_SOCKET + /// environment variable. If there NOTIFY_SOCKET environment variable does + /// not exist then no message is sent. + fn send(message: []const u8) void { + // systemd is Linux-only so this is a no-op anywhere else + if (comptime builtin.os.tag != .linux) return; + + // Get the socket address that should receive notifications. + const socket_path = std.posix.getenv("NOTIFY_SOCKET") orelse return; + + // If the socket address is an empty string return. + if (socket_path.len == 0) return; + + // The socket address must be a path or an abstract socket. + if (socket_path[0] != '/' and socket_path[0] != '@') { + log.warn("only AF_UNIX sockets with path or abstract namespace addresses are supported!", .{}); + return; + } + + var socket_address: std.os.linux.sockaddr.un = undefined; + + // Error out if the supplied socket path is too long. + if (socket_address.path.len < socket_path.len) { + log.warn("NOTIFY_SOCKET path is too long!", .{}); + return; + } + + socket_address.family = std.os.linux.AF.UNIX; + + @memcpy(socket_address.path[0..socket_path.len], socket_path); + socket_address.path[socket_path.len] = 0; + + const socket: std.os.linux.socket_t = socket: { + const rc = std.os.linux.socket( + std.os.linux.AF.UNIX, + std.os.linux.SOCK.DGRAM | std.os.linux.SOCK.CLOEXEC, + 0, + ); + switch (std.os.linux.E.init(rc)) { + .SUCCESS => break :socket @intCast(rc), + else => |e| { + log.warn("creating socket failed: {s}", .{@tagName(e)}); + return; + }, + } + }; + + defer _ = std.os.linux.close(socket); + + connect: { + const rc = std.os.linux.connect( + socket, + &socket_address, + @offsetOf(std.os.linux.sockaddr.un, "path") + socket_address.path.len, + ); + switch (std.os.linux.E.init(rc)) { + .SUCCESS => break :connect, + else => |e| { + log.warn("unable to connect to notify socket: {s}", .{@tagName(e)}); + return; + }, + } + } + + write: { + const rc = std.os.linux.write(socket, message.ptr, message.len); + switch (std.os.linux.E.init(rc)) { + .SUCCESS => { + const written = rc; + if (written < message.len) { + log.warn("short write to notify socket: {d} < {d}", .{ rc, message.len }); + return; + } + break :write; + }, + else => |e| { + log.warn("unable to write to notify socket: {s}", .{@tagName(e)}); + return; + }, + } + } + } + + /// Tell systemd that we are ready or that we are finished reloading. + /// See: https://www.freedesktop.org/software/systemd/man/latest/sd_notify.html#READY=1 + pub fn ready() void { + if (comptime builtin.os.tag != .linux) return; + + send("READY=1"); + } + + /// Tell systemd that we have started reloading our configuration. + /// See: https://www.freedesktop.org/software/systemd/man/latest/sd_notify.html#RELOADING=1 + /// and: https://www.freedesktop.org/software/systemd/man/latest/sd_notify.html#MONOTONIC_USEC=%E2%80%A6 + pub fn reloading() void { + if (comptime builtin.os.tag != .linux) return; + + const ts = std.posix.clock_gettime(.MONOTONIC) catch |err| { + log.err("unable to get MONOTONIC clock: {}", .{err}); + return; + }; + + const now = ts.sec * std.time.us_per_s + @divFloor(ts.nsec, std.time.ns_per_us); + + var buffer: [64]u8 = undefined; + const message = std.fmt.bufPrint(&buffer, "RELOADING=1\nMONOTONIC_USEC={d}", .{now}) catch |err| { + log.err("unable to format reloading message: {}", .{err}); + return; + }; + + send(message); + } +}; From c9d0bbefc2a2e5152996113a78315fc723b155ec Mon Sep 17 00:00:00 2001 From: "Jeffrey C. Ollie" Date: Fri, 4 Jul 2025 15:01:54 -0500 Subject: [PATCH 59/94] linux: switch systemd user service to type=notify-reload This allows `systemctl` to send SIGUSR2 to Ghostty to trigger a reload, which is more convenient than scripting `ps` and `kill` to find the Ghostty main PID. --- dist/linux/systemd.service.in | 5 ++++- src/apprt/gtk/App.zig | 10 ++++++++++ 2 files changed, 14 insertions(+), 1 deletion(-) diff --git a/dist/linux/systemd.service.in b/dist/linux/systemd.service.in index 3ff848ddd..76ccdd3f4 100644 --- a/dist/linux/systemd.service.in +++ b/dist/linux/systemd.service.in @@ -1,9 +1,12 @@ [Unit] Description=@NAME@ After=graphical-session.target +After=dbus.socket +Requires=dbus.socket [Service] -Type=dbus +Type=notify-reload +ReloadSignal=SIGUSR2 BusName=@APPID@ ExecStart=@GHOSTTY@ --launched-from=systemd diff --git a/src/apprt/gtk/App.zig b/src/apprt/gtk/App.zig index 907f3a36d..bdb2f0f24 100644 --- a/src/apprt/gtk/App.zig +++ b/src/apprt/gtk/App.zig @@ -29,6 +29,7 @@ const apprt = @import("../../apprt.zig"); const configpkg = @import("../../config.zig"); const input = @import("../../input.zig"); const internal_os = @import("../../os/main.zig"); +const systemd = @import("../../os/systemd.zig"); const terminal = @import("../../terminal/main.zig"); const Config = configpkg.Config; const CoreApp = @import("../../App.zig"); @@ -1035,6 +1036,12 @@ pub fn reloadConfig( target: apprt.action.Target, opts: apprt.action.ReloadConfig, ) !void { + // Tell systemd that reloading has started. + systemd.notify.reloading(); + + // When we exit this function tell systemd that reloading has finished. + defer systemd.notify.ready(); + if (opts.soft) { switch (target) { .app => try self.core_app.updateConfig(self, &self.config), @@ -1367,6 +1374,9 @@ pub fn run(self: *App) !void { log.warn("error handling configuration changes err={}", .{err}); }; + // Tell systemd that we are ready. + systemd.notify.ready(); + while (self.running) { _ = glib.MainContext.iteration(self.ctx, 1); From 248acbea5b5f8988b3e5cd44538b756025962bf6 Mon Sep 17 00:00:00 2001 From: "Jeffrey C. Ollie" Date: Wed, 9 Jul 2025 12:29:45 -0500 Subject: [PATCH 60/94] gtk: remove NOTIFY_SOCKET from the inherited environment variables --- src/apprt/gtk/Surface.zig | 1 + 1 file changed, 1 insertion(+) diff --git a/src/apprt/gtk/Surface.zig b/src/apprt/gtk/Surface.zig index 5c886e663..d16083d5a 100644 --- a/src/apprt/gtk/Surface.zig +++ b/src/apprt/gtk/Surface.zig @@ -2333,6 +2333,7 @@ pub fn defaultTermioEnv(self: *Surface) !std.process.EnvMap { env.remove("DBUS_STARTER_BUS_TYPE"); env.remove("INVOCATION_ID"); env.remove("JOURNAL_STREAM"); + env.remove("NOTIFY_SOCKET"); // Unset environment varies set by snaps if we're running in a snap. // This allows Ghostty to further launch additional snaps. From a1cb52dcd35794c61dc7fb583f25284b7b6347d5 Mon Sep 17 00:00:00 2001 From: Jon Parise Date: Wed, 9 Jul 2025 14:29:46 -0400 Subject: [PATCH 61/94] fish: prefer 'command -q' to check for commands This is a fish built-in 'command' option that's the more idiomatic way to check for the availability of a command. https://fishshell.com/docs/current/cmds/command.html --- .../fish/vendor_conf.d/ghostty-shell-integration.fish | 6 +++--- 1 file changed, 3 insertions(+), 3 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 0bba43b31..546f05fc8 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 @@ -124,9 +124,9 @@ function __ghostty_setup --on-event fish_prompt -d "Setup ghostty integration" if test -n "$ssh_hostname" # Check if terminfo is already cached - if command -v ghostty >/dev/null 2>&1; and ghostty +ssh-cache --host="$ssh_target" >/dev/null 2>&1 + if command -q ghostty; 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 + else if command -q infocmp set -l ssh_terminfo set -l ssh_cpath_dir set -l ssh_cpath @@ -149,7 +149,7 @@ function __ghostty_setup --on-event fish_prompt -d "Setup ghostty integration" set -a ssh_opts -o "ControlPath=$ssh_cpath" # Cache successful installation - if test -n "$ssh_target"; and command -v ghostty >/dev/null 2>&1 + if test -n "$ssh_target"; and command -q ghostty ghostty +ssh-cache --add="$ssh_target" >/dev/null 2>&1; or true end else From e522d54d7b715dfd34fd9e8e36cd9d42d1527ef6 Mon Sep 17 00:00:00 2001 From: Jon Parise Date: Wed, 9 Jul 2025 15:59:59 -0400 Subject: [PATCH 62/94] shell-integration: simplify "ssh target" checks This value is always set to a non-empty string, and we only need this value after we've determined that 'ssh_hostname' is non-empty. In bash and zsh, we also don't need to check for the 'ghostty' command before we attempt to add the target to the cache. That command will safely fail silently if it's not available. --- src/shell-integration/bash/ghostty.bash | 8 +++----- src/shell-integration/elvish/lib/ghostty-integration.elv | 6 +++--- .../fish/vendor_conf.d/ghostty-shell-integration.fish | 2 +- src/shell-integration/zsh/ghostty-integration | 8 +++----- 4 files changed, 10 insertions(+), 14 deletions(-) diff --git a/src/shell-integration/bash/ghostty.bash b/src/shell-integration/bash/ghostty.bash index 63255bbc3..5b338b11e 100644 --- a/src/shell-integration/bash/ghostty.bash +++ b/src/shell-integration/bash/ghostty.bash @@ -120,9 +120,9 @@ if [[ "$GHOSTTY_SHELL_FEATURES" == *ssh-* ]]; then [[ -n "$ssh_user" && -n "$ssh_hostname" ]] && break done < <(builtin command ssh -G "$@" 2>/dev/null) - builtin local ssh_target="${ssh_user}@${ssh_hostname}" - if [[ -n "$ssh_hostname" ]]; then + builtin local ssh_target="${ssh_user}@${ssh_hostname}" + # Check if terminfo is already cached if ghostty +ssh-cache --host="$ssh_target" >/dev/null 2>&1; then ssh_term="xterm-ghostty" @@ -147,9 +147,7 @@ if [[ "$GHOSTTY_SHELL_FEATURES" == *ssh-* ]]; then 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 + ghostty +ssh-cache --add="$ssh_target" >/dev/null 2>&1 || true else builtin echo "Warning: Failed to install terminfo." >&2 fi diff --git a/src/shell-integration/elvish/lib/ghostty-integration.elv b/src/shell-integration/elvish/lib/ghostty-integration.elv index 2eadbfd06..4e95b251f 100644 --- a/src/shell-integration/elvish/lib/ghostty-integration.elv +++ b/src/shell-integration/elvish/lib/ghostty-integration.elv @@ -137,9 +137,9 @@ } } - var ssh-target = $ssh-user"@"$ssh-hostname - if (not-eq $ssh-hostname "") { + var ssh-target = $ssh-user"@"$ssh-hostname + # Check if terminfo is already cached if (and (has-external ghostty) (bool ?(external ghostty +ssh-cache --host=$ssh-target >/dev/null 2>&1))) { set ssh-term = "xterm-ghostty" @@ -167,7 +167,7 @@ set ssh-opts = (conj $ssh-opts -o ControlPath=$ssh-cpath) # Cache successful installation - if (and (not-eq $ssh-target "") (has-external ghostty)) { + if (has-external ghostty) { external ghostty +ssh-cache --add=$ssh-target >/dev/null 2>&1 } } 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 546f05fc8..5381f834b 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 @@ -149,7 +149,7 @@ function __ghostty_setup --on-event fish_prompt -d "Setup ghostty integration" set -a ssh_opts -o "ControlPath=$ssh_cpath" # Cache successful installation - if test -n "$ssh_target"; and command -q ghostty + if command -q ghostty ghostty +ssh-cache --add="$ssh_target" >/dev/null 2>&1; or true end else diff --git a/src/shell-integration/zsh/ghostty-integration b/src/shell-integration/zsh/ghostty-integration index 60101416e..f3fb46180 100644 --- a/src/shell-integration/zsh/ghostty-integration +++ b/src/shell-integration/zsh/ghostty-integration @@ -272,9 +272,9 @@ _ghostty_deferred_init() { [[ -n "$ssh_user" && -n "$ssh_hostname" ]] && break done < <(command ssh -G "$@" 2>/dev/null) - local ssh_target="${ssh_user}@${ssh_hostname}" - if [[ -n "$ssh_hostname" ]]; then + local ssh_target="${ssh_user}@${ssh_hostname}" + # Check if terminfo is already cached if (( $+commands[ghostty] )) && ghostty +ssh-cache --host="$ssh_target" >/dev/null 2>&1; then ssh_term="xterm-ghostty" @@ -299,9 +299,7 @@ _ghostty_deferred_init() { 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 + ghostty +ssh-cache --add="$ssh_target" >/dev/null 2>&1 || true else print "Warning: Failed to install terminfo." >&2 fi From 9818543ebee1e74ea9f8c32d9e08beec72f54543 Mon Sep 17 00:00:00 2001 From: Hojin You Date: Wed, 9 Jul 2025 16:27:22 -0400 Subject: [PATCH 63/94] Update Korean Translations --- po/ko_KR.UTF-8.po | 15 +++++++-------- 1 file changed, 7 insertions(+), 8 deletions(-) diff --git a/po/ko_KR.UTF-8.po b/po/ko_KR.UTF-8.po index 9aa4aad5e..3482453ed 100644 --- a/po/ko_KR.UTF-8.po +++ b/po/ko_KR.UTF-8.po @@ -8,8 +8,8 @@ msgstr "" "Project-Id-Version: com.mitchellh.ghostty\n" "Report-Msgid-Bugs-To: m@mitchellh.com\n" "POT-Creation-Date: 2025-07-08 15:13-0500\n" -"PO-Revision-Date: 2025-03-31 03:08+0200\n" -"Last-Translator: Ruben Engelbrecht \n" +"PO-Revision-Date: 2025-07-09 16:11-0400\n" +"Last-Translator: Hojin You \n" "Language-Team: Korean \n" "Language: ko\n" "MIME-Version: 1.0\n" @@ -87,7 +87,7 @@ msgstr "오른쪽으로 창 나누기" #: src/apprt/gtk/ui/1.5/command-palette.blp:16 msgid "Execute a command…" -msgstr "" +msgstr "명령을 실행하세요…" #: src/apprt/gtk/ui/1.0/menu-surface-context_menu.blp:6 #: src/apprt/gtk/ui/1.0/menu-window-titlebar_menu.blp:6 @@ -160,7 +160,7 @@ msgstr "설정 열기" #: src/apprt/gtk/ui/1.0/menu-window-titlebar_menu.blp:85 msgid "Command Palette" -msgstr "" +msgstr "명령 팔레트" #: src/apprt/gtk/ui/1.0/menu-window-titlebar_menu.blp:90 msgid "Terminal Inspector" @@ -208,12 +208,12 @@ msgstr "허용" #: src/apprt/gtk/ui/1.5/ccw-osc-52-read.blp:81 #: src/apprt/gtk/ui/1.5/ccw-osc-52-write.blp:77 msgid "Remember choice for this split" -msgstr "" +msgstr "이 분할에 대한 선택 기억하기" #: src/apprt/gtk/ui/1.5/ccw-osc-52-read.blp:82 #: src/apprt/gtk/ui/1.5/ccw-osc-52-write.blp:78 msgid "Reload configuration to show this prompt again" -msgstr "" +msgstr "이 프롬프트를 다시 표시하려면 구성을 다시 로드하십시오." #: src/apprt/gtk/ui/1.5/ccw-osc-52-write.blp:7 #: src/apprt/gtk/ui/1.2/ccw-osc-52-write.blp:7 @@ -285,9 +285,8 @@ msgid "View Open Tabs" msgstr "열린 탭 보기" #: src/apprt/gtk/Window.zig:266 -#, fuzzy msgid "New Split" -msgstr "나누기" +msgstr "새 분할" #: src/apprt/gtk/Window.zig:329 msgid "" From f5f2a4dd20642d7ca1d3f380349eb83762f1eb7e Mon Sep 17 00:00:00 2001 From: Jon Parise Date: Wed, 9 Jul 2025 17:25:34 -0400 Subject: [PATCH 64/94] shell-integration: use $GHOSTTY_BIN_DIR/ghostty Locate our ghostty binary using $GHOSTTY_BIN_DIR rather than searching the PATH. --- src/shell-integration/bash/ghostty.bash | 4 ++-- .../fish/vendor_conf.d/ghostty-shell-integration.fish | 10 +++++----- src/shell-integration/zsh/ghostty-integration | 4 ++-- 3 files changed, 9 insertions(+), 9 deletions(-) diff --git a/src/shell-integration/bash/ghostty.bash b/src/shell-integration/bash/ghostty.bash index 5b338b11e..aacf37c3a 100644 --- a/src/shell-integration/bash/ghostty.bash +++ b/src/shell-integration/bash/ghostty.bash @@ -124,7 +124,7 @@ if [[ "$GHOSTTY_SHELL_FEATURES" == *ssh-* ]]; then builtin local ssh_target="${ssh_user}@${ssh_hostname}" # Check if terminfo is already cached - if ghostty +ssh-cache --host="$ssh_target" >/dev/null 2>&1; then + if "$GHOSTTY_BIN_DIR/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 @@ -147,7 +147,7 @@ if [[ "$GHOSTTY_SHELL_FEATURES" == *ssh-* ]]; then ssh_opts+=(-o "ControlPath=$ssh_cpath") # Cache successful installation - ghostty +ssh-cache --add="$ssh_target" >/dev/null 2>&1 || true + "$GHOSTTY_BIN_DIR/ghostty" +ssh-cache --add="$ssh_target" >/dev/null 2>&1 || true else builtin echo "Warning: Failed to install terminfo." >&2 fi 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 5381f834b..834f0ef10 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 @@ -120,11 +120,11 @@ function __ghostty_setup --on-event fish_prompt -d "Setup ghostty integration" end end - set -l ssh_target "$ssh_user@$ssh_hostname" - if test -n "$ssh_hostname" + set -l ssh_target "$ssh_user@$ssh_hostname" + # Check if terminfo is already cached - if command -q ghostty; and ghostty +ssh-cache --host="$ssh_target" >/dev/null 2>&1 + if test -x "$GHOSTTY_BIN_DIR/ghostty"; and "$GHOSTTY_BIN_DIR/ghostty" +ssh-cache --host="$ssh_target" >/dev/null 2>&1 set ssh_term "xterm-ghostty" else if command -q infocmp set -l ssh_terminfo @@ -149,8 +149,8 @@ function __ghostty_setup --on-event fish_prompt -d "Setup ghostty integration" set -a ssh_opts -o "ControlPath=$ssh_cpath" # Cache successful installation - if command -q ghostty - ghostty +ssh-cache --add="$ssh_target" >/dev/null 2>&1; or true + if test -x "$GHOSTTY_BIN_DIR/ghostty" + "$GHOSTTY_BIN_DIR/ghostty" +ssh-cache --add="$ssh_target" >/dev/null 2>&1; or true end else echo "Warning: Failed to install terminfo." >&2 diff --git a/src/shell-integration/zsh/ghostty-integration b/src/shell-integration/zsh/ghostty-integration index f3fb46180..8607664a2 100644 --- a/src/shell-integration/zsh/ghostty-integration +++ b/src/shell-integration/zsh/ghostty-integration @@ -276,7 +276,7 @@ _ghostty_deferred_init() { local ssh_target="${ssh_user}@${ssh_hostname}" # Check if terminfo is already cached - if (( $+commands[ghostty] )) && ghostty +ssh-cache --host="$ssh_target" >/dev/null 2>&1; then + if "$GHOSTTY_BIN_DIR/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 @@ -299,7 +299,7 @@ _ghostty_deferred_init() { ssh_opts+=(-o "ControlPath=$ssh_cpath") # Cache successful installation - ghostty +ssh-cache --add="$ssh_target" >/dev/null 2>&1 || true + "$GHOSTTY_BIN_DIR/ghostty" +ssh-cache --add="$ssh_target" >/dev/null 2>&1 || true else print "Warning: Failed to install terminfo." >&2 fi From 8506637ae614237a39f3a167e94bb998979ffb25 Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Mon, 7 Jul 2025 14:14:28 -0700 Subject: [PATCH 65/94] macos: add signpost API --- pkg/macos/build.zig | 7 +- pkg/macos/main.zig | 1 + pkg/macos/os.zig | 1 + pkg/macos/os/signpost.zig | 161 ++++++++++++++++++++++++ pkg/macos/os/{zig_log.c => zig_macos.c} | 1 + 5 files changed, 166 insertions(+), 5 deletions(-) create mode 100644 pkg/macos/os/signpost.zig rename pkg/macos/os/{zig_log.c => zig_macos.c} (90%) diff --git a/pkg/macos/build.zig b/pkg/macos/build.zig index 3e0a97d1a..ddbeb82c9 100644 --- a/pkg/macos/build.zig +++ b/pkg/macos/build.zig @@ -18,15 +18,12 @@ pub fn build(b: *std.Build) !void { .optimize = optimize, }); - var flags = std.ArrayList([]const u8).init(b.allocator); - defer flags.deinit(); lib.addCSourceFile(.{ - .file = b.path("os/zig_log.c"), - .flags = flags.items, + .file = b.path("os/zig_macos.c"), + .flags = &.{"-std=c99"}, }); lib.addCSourceFile(.{ .file = b.path("text/ext.c"), - .flags = flags.items, }); lib.linkFramework("CoreFoundation"); lib.linkFramework("CoreGraphics"); diff --git a/pkg/macos/main.zig b/pkg/macos/main.zig index 42253ba48..e4f4b9504 100644 --- a/pkg/macos/main.zig +++ b/pkg/macos/main.zig @@ -23,6 +23,7 @@ pub const c = @cImport({ @cInclude("IOSurface/IOSurfaceRef.h"); @cInclude("dispatch/dispatch.h"); @cInclude("os/log.h"); + @cInclude("os/signpost.h"); if (builtin.os.tag == .macos) { @cInclude("Carbon/Carbon.h"); diff --git a/pkg/macos/os.zig b/pkg/macos/os.zig index 183913bac..9716a9abc 100644 --- a/pkg/macos/os.zig +++ b/pkg/macos/os.zig @@ -1,6 +1,7 @@ const log = @import("os/log.zig"); pub const c = @import("os/c.zig"); +pub const signpost = @import("os/signpost.zig"); pub const Log = log.Log; pub const LogType = log.LogType; diff --git a/pkg/macos/os/signpost.zig b/pkg/macos/os/signpost.zig new file mode 100644 index 000000000..9fef584e4 --- /dev/null +++ b/pkg/macos/os/signpost.zig @@ -0,0 +1,161 @@ +const std = @import("std"); +const assert = std.debug.assert; +const Allocator = std.mem.Allocator; +const c = @import("c.zig").c; +const logpkg = @import("log.zig"); +const Log = logpkg.Log; + +/// Checks whether signpost logging is enabled for the given log handle. +/// Returns true if signposts will be recorded for this log, false otherwise. +/// This can be used to avoid expensive operations when signpost logging is disabled. +/// +/// https://developer.apple.com/documentation/os/os_signpost_enabled?language=objc +pub fn enabled(log: *Log) bool { + return c.os_signpost_enabled(@ptrCast(log)); +} + +/// Emits a signpost event - a single point in time marker. +/// Events are useful for marking when specific actions occur, such as +/// user interactions, state changes, or other discrete occurrences. +/// The event will appear as a vertical line in Instruments. +/// +/// https://developer.apple.com/documentation/os/os_signpost_event_emit?language=objc +pub fn emitEvent( + log: *Log, + id: Id, + comptime name: [:0]const u8, +) void { + emitWithName(log, id, .event, name); +} + +/// Marks the beginning of a time interval. +/// Use this with intervalEnd to measure the duration of operations. +/// The same ID must be used for both the begin and end calls. +/// Intervals appear as horizontal bars in Instruments timeline. +/// +/// https://developer.apple.com/documentation/os/os_signpost_interval_begin?language=objc +pub fn intervalBegin(log: *Log, id: Id, comptime name: [:0]const u8) void { + emitWithName(log, id, .interval_begin, name); +} + +/// Marks the end of a time interval. +/// Must be paired with a prior intervalBegin call using the same ID. +/// The name should match the name used in intervalBegin. +/// Instruments will calculate and display the duration between begin and end. +/// +/// https://developer.apple.com/documentation/os/os_signpost_interval_end?language=objc +pub fn intervalEnd(log: *Log, id: Id, comptime name: [:0]const u8) void { + emitWithName(log, id, .interval_end, name); +} + +extern var __dso_handle: usize; + +/// The internal function to emit a signpost with a specific name. +fn emitWithName( + log: *Log, + id: Id, + typ: Type, + comptime name: [:0]const u8, +) void { + var buf: [64]u8 = @splat(0); + c._os_signpost_emit_with_name_impl( + &__dso_handle, + @ptrCast(log), + @intFromEnum(typ), + @intFromEnum(id), + name.ptr, + null, + &buf, + buf.len, + ); +} + +/// https://developer.apple.com/documentation/os/os_signpost_id_t?language=objc +pub const Id = enum(u64) { + null = 0, // OS_SIGNPOST_ID_NULL + invalid = 0xFFFFFFFFFFFFFFFF, // OS_SIGNPOST_ID_INVALID + exclusive = 0xEEEEB0B5B2B2EEEE, // OS_SIGNPOST_ID_EXCLUSIVE + _, + + /// Generates a new signpost ID for use with signpost operations. + /// The ID is unique for the given log handle and can be used to track + /// asynchronous operations or mark specific points of interest in the code. + /// Returns a unique signpost ID that can be used with os_signpost functions. + /// + /// https://developer.apple.com/documentation/os/os_signpost_id_generate?language=objc + pub fn generate(log: *Log) Id { + return @enumFromInt(c.os_signpost_id_generate(@ptrCast(log))); + } + + /// Creates a signpost ID based on a pointer value. + /// This is useful for tracking operations associated with a specific object + /// or memory location. The same pointer will always generate the same ID + /// for a given log handle, allowing correlation of signpost events. + /// Pass null to get the null signpost ID. + /// + /// https://developer.apple.com/documentation/os/os_signpost_id_for_pointer?language=objc + pub fn forPointer(log: *Log, ptr: ?*anyopaque) Id { + return @enumFromInt(c.os_signpost_id_make_with_pointer( + @ptrCast(log), + @ptrCast(ptr), + )); + } + + test "generate ID" { + // We can't really test the return value because it may return null + // if signposts are disabled. + const id: Id = .generate(Log.create("com.mitchellh.ghostty", "test")); + try std.testing.expect(id != .invalid); + } + + test "generate ID for pointer" { + var foo: usize = 0x1234; + const id: Id = .forPointer(Log.create("com.mitchellh.ghostty", "test"), &foo); + try std.testing.expect(id != .null); + } +}; + +/// https://developer.apple.com/documentation/os/ossignposttype?language=objc +pub const Type = enum(u8) { + event = 0, // OS_SIGNPOST_EVENT + interval_begin = 1, // OS_SIGNPOST_INTERVAL_BEGIN + interval_end = 2, // OS_SIGNPOST_INTERVAL_END + + pub const mask: u8 = 0x03; // OS_SIGNPOST_TYPE_MASK +}; + +/// Special os_log category values that surface in Instruments and other +/// tooling. +pub const Category = struct { + /// Points of Interest appear as a dedicated track in Instruments. + /// Use this for high-level application events that help understand + /// the flow of your application. + pub const points_of_interest: [:0]const u8 = "PointsOfInterest"; + + /// Dynamic Tracing category enables runtime-configurable logging. + /// Signposts in this category can be enabled/disabled dynamically + /// without recompiling. + pub const dynamic_tracing: [:0]const u8 = "DynamicTracking"; + + /// Dynamic Stack Tracing category captures call stacks at signpost + /// events. This provides deeper debugging information but has higher + /// performance overhead. + pub const dynamic_stack_tracing: [:0]const u8 = "DynamicStackTracking"; +}; + +test { + _ = Id; +} + +test enabled { + _ = enabled(Log.create("com.mitchellh.ghostty", "test")); +} + +test "intervals" { + const log = Log.create("com.mitchellh.ghostty", "test"); + defer log.release(); + + // Test that we can begin and end an interval + const id = Id.generate(log); + intervalBegin(log, id, "Test Interval"); +} diff --git a/pkg/macos/os/zig_log.c b/pkg/macos/os/zig_macos.c similarity index 90% rename from pkg/macos/os/zig_log.c rename to pkg/macos/os/zig_macos.c index ef3f616d5..1c4f06982 100644 --- a/pkg/macos/os/zig_log.c +++ b/pkg/macos/os/zig_macos.c @@ -1,4 +1,5 @@ #include +#include // A wrapper so we can use the os_log_with_type macro. void zig_os_log_with_type( From 0e8ccc73529758e2bbdcebcbb0c9835ba1d41f6c Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Mon, 7 Jul 2025 16:36:17 -0700 Subject: [PATCH 66/94] benchmark: a new package and framework for benchmarking --- src/benchmark/Benchmark.zig | 165 +++++++++++++++++++++++++++++++ src/benchmark/TerminalStream.zig | 97 ++++++++++++++++++ src/benchmark/main.zig | 6 ++ src/main_ghostty.zig | 1 + 4 files changed, 269 insertions(+) create mode 100644 src/benchmark/Benchmark.zig create mode 100644 src/benchmark/TerminalStream.zig create mode 100644 src/benchmark/main.zig diff --git a/src/benchmark/Benchmark.zig b/src/benchmark/Benchmark.zig new file mode 100644 index 000000000..b7d9b6ad3 --- /dev/null +++ b/src/benchmark/Benchmark.zig @@ -0,0 +1,165 @@ +//! A single benchmark case. +const Benchmark = @This(); + +const std = @import("std"); +const builtin = @import("builtin"); +const assert = std.debug.assert; +const macos = @import("macos"); +const build_config = @import("../build_config.zig"); + +ptr: *anyopaque, +vtable: VTable, + +/// Create a new benchmark from a pointer and a vtable. +/// +/// This usually is only called by benchmark implementations, not +/// benchmark users. +pub fn init( + pointer: anytype, + vtable: VTable, +) Benchmark { + const Ptr = @TypeOf(pointer); + assert(@typeInfo(Ptr) == .pointer); // Must be a pointer + assert(@typeInfo(Ptr).pointer.size == .one); // Must be a single-item pointer + assert(@typeInfo(@typeInfo(Ptr).pointer.child) == .@"struct"); // Must point to a struct + return .{ .ptr = pointer, .vtable = vtable }; +} + +/// Run the benchmark. +pub fn run( + self: Benchmark, + mode: RunMode, +) Error!RunResult { + // Run our setup function if it exists. We do this first because + // we don't want this part of our benchmark and we want to fail fast. + if (self.vtable.setupFn) |func| try func(self.ptr); + defer if (self.vtable.teardownFn) |func| func(self.ptr); + + // Our result accumulator. This will be returned at the end of the run. + var result: RunResult = .{}; + + // If we're on macOS, we setup signposts so its easier to find + // the results in Instruments. There's a lot of nasty comptime stuff + // here but its just to ensure this does nothing on other platforms. + const signpost_name = "Ghostty Benchmark"; + const signpost: if (builtin.target.os.tag.isDarwin()) struct { + log: *macos.os.Log, + id: macos.os.signpost.Id, + } else void = if (comptime builtin.os.tag == .macos) macos: { + const log = macos.os.Log.create( + build_config.bundle_id, + macos.os.signpost.Category.points_of_interest, + ); + const id = macos.os.signpost.Id.generate(log); + macos.os.signpost.intervalBegin(log, id, signpost_name); + break :macos .{ .log = log, .id = id }; + } else {}; + defer if (comptime builtin.os.tag == .macos) { + macos.os.signpost.intervalEnd( + signpost.log, + signpost.id, + signpost_name, + ); + signpost.log.release(); + }; + + const start = std.time.Instant.now() catch return error.BenchmarkFailed; + while (true) { + // Run our step function. If it fails, we return the error. + try self.vtable.stepFn(self.ptr); + result.iterations += 1; + + // Get our current monotonic time and check our exit conditions. + const now = std.time.Instant.now() catch return error.BenchmarkFailed; + const exit = switch (mode) { + .once => true, + .duration => |ns| now.since(start) >= ns, + }; + + if (exit) { + result.duration = now.since(start); + return result; + } + } + + // We exit within the loop body. + unreachable; +} + +/// The type of benchmark run. This is used to determine how the benchmark +/// is executed. +pub const RunMode = union(enum) { + /// Run the benchmark exactly once. + once, + + /// Run the benchmark for a fixed duration in nanoseconds. This + /// will not interrupt a running step so if the granularity of the + /// duration is too low, benchmark results may be inaccurate. + duration: u64, +}; + +/// The result of a benchmark run. +pub const RunResult = struct { + /// The total iterations that step was executed. For "once" run + /// modes this will always be 1. + iterations: u32 = 0, + + /// The total time taken for the run. For "duration" run modes + /// this will be relatively close to the requested duration. + /// The units are nanoseconds. + duration: u64 = 0, +}; + +/// The possible errors that can occur during various stages of the +/// benchmark. Right now its just "failure" which ends the benchmark. +pub const Error = error{BenchmarkFailed}; + +/// The vtable that must be provided to invoke the real implementation. +pub const VTable = struct { + /// A single step to execute the benchmark. This should do the work + /// that is under test. This may be called multiple times if we're + /// testing throughput. + stepFn: *const fn (ptr: *anyopaque) Error!void, + + /// Setup and teardown functions. These are called once before + /// the first step and once after the last step. They are not part + /// of the benchmark results (unless you're benchmarking the full + /// binary). + setupFn: ?*const fn (ptr: *anyopaque) Error!void = null, + teardownFn: ?*const fn (ptr: *anyopaque) void = null, +}; + +test Benchmark { + const testing = std.testing; + const Simple = struct { + const Self = @This(); + + setup_i: usize = 0, + step_i: usize = 0, + + pub fn benchmark(self: *Self) Benchmark { + return .init(self, .{ + .stepFn = step, + .setupFn = setup, + }); + } + + fn setup(ptr: *anyopaque) Error!void { + const self: *Self = @ptrCast(@alignCast(ptr)); + self.setup_i += 1; + } + + fn step(ptr: *anyopaque) Error!void { + const self: *Self = @ptrCast(@alignCast(ptr)); + self.step_i += 1; + } + }; + + var s: Simple = .{}; + const b = s.benchmark(); + const result = try b.run(.once); + try testing.expectEqual(1, s.setup_i); + try testing.expectEqual(1, s.step_i); + try testing.expectEqual(1, result.iterations); + try testing.expect(result.duration > 0); +} diff --git a/src/benchmark/TerminalStream.zig b/src/benchmark/TerminalStream.zig new file mode 100644 index 000000000..8580695f2 --- /dev/null +++ b/src/benchmark/TerminalStream.zig @@ -0,0 +1,97 @@ +//! This benchmark tests the performance of the terminal stream +//! handler from input to terminal state update. This is useful to +//! test general throughput of VT parsing and handling. +//! +//! Note that the handler used for this benchmark isn't the full +//! terminal handler, since that requires a significant amount of +//! state. This is a simplified version that only handles specific +//! terminal operations like printing characters. We should expand +//! this to include more operations to improve the accuracy of the +//! benchmark. +//! +//! It is a fairly broad benchmark that can be used to determine +//! if we need to optimize something more specific (e.g. the parser). +const TerminalStream = @This(); + +const std = @import("std"); +const Allocator = std.mem.Allocator; +const terminalpkg = @import("../terminal/main.zig"); +const Benchmark = @import("Benchmark.zig"); +const Terminal = terminalpkg.Terminal; +const Stream = terminalpkg.Stream(*Handler); + +terminal: Terminal, +handler: Handler, +stream: Stream, + +pub const Options = struct { + /// The size of the terminal. This affects benchmarking when + /// dealing with soft line wrapping and the memory impact + /// of page sizes. + @"terminal-rows": u16 = 80, + @"terminal-cols": u16 = 120, +}; + +/// Create a new terminal stream handler for the given arguments. +pub fn create( + alloc: Allocator, + args: Options, +) !*TerminalStream { + const ptr = try alloc.create(TerminalStream); + errdefer alloc.destroy(ptr); + + ptr.* = .{ + .terminal = try .init(alloc, .{ + .rows = args.@"terminal-rows", + .cols = args.@"terminal-cols", + }), + .handler = .{ .t = &ptr.terminal }, + .stream = .{ .handler = &ptr.handler }, + }; + + return ptr; +} + +pub fn destroy(self: *TerminalStream, alloc: Allocator) void { + self.terminal.deinit(alloc); + alloc.destroy(self); +} + +pub fn benchmark(self: *TerminalStream) Benchmark { + return .init(self, .{ + .stepFn = step, + .setupFn = setup, + }); +} + +fn setup(ptr: *anyopaque) Benchmark.Error!void { + const self: *TerminalStream = @ptrCast(@alignCast(ptr)); + self.terminal.fullReset(); +} + +fn step(ptr: *anyopaque) Benchmark.Error!void { + const self: *TerminalStream = @ptrCast(@alignCast(ptr)); + _ = self; +} + +/// Implements the handler interface for the terminal.Stream. +/// We should expand this to include more operations to make +/// our benchmark more realistic. +const Handler = struct { + t: *Terminal, + + pub fn print(self: *Handler, cp: u21) !void { + try self.t.print(cp); + } +}; + +test TerminalStream { + const testing = std.testing; + const alloc = testing.allocator; + + const impl: *TerminalStream = try .create(alloc, .{}); + defer impl.destroy(alloc); + + const bench = impl.benchmark(); + _ = try bench.run(.once); +} diff --git a/src/benchmark/main.zig b/src/benchmark/main.zig new file mode 100644 index 000000000..802519e29 --- /dev/null +++ b/src/benchmark/main.zig @@ -0,0 +1,6 @@ +pub const Benchmark = @import("Benchmark.zig"); +pub const TerminalStream = @import("TerminalStream.zig"); + +test { + _ = @import("std").testing.refAllDecls(@This()); +} diff --git a/src/main_ghostty.zig b/src/main_ghostty.zig index b747fe6f0..fb29303f1 100644 --- a/src/main_ghostty.zig +++ b/src/main_ghostty.zig @@ -182,6 +182,7 @@ test { _ = @import("surface_mouse.zig"); // Libraries + _ = @import("benchmark/main.zig"); _ = @import("crash/main.zig"); _ = @import("datastruct/main.zig"); _ = @import("inspector/main.zig"); From 1739418f6f6fab3bb7df9c2c84eba91ddabe91b2 Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Tue, 8 Jul 2025 08:55:34 -0700 Subject: [PATCH 67/94] cli: make the action parser (+foo) generic and reusable --- src/build/bash_completions.zig | 2 +- src/build/fish_completions.zig | 2 +- src/build/mdgen/mdgen.zig | 2 +- src/build/zsh_completions.zig | 2 +- src/cli.zig | 3 +- src/cli/action.zig | 511 +++++++++++++++------------------ src/cli/boo.zig | 2 +- src/cli/crash_report.zig | 2 +- src/cli/edit_config.zig | 2 +- src/cli/ghostty.zig | 290 +++++++++++++++++++ src/cli/help.zig | 2 +- src/cli/list_actions.zig | 2 +- src/cli/list_colors.zig | 2 +- src/cli/list_fonts.zig | 2 +- src/cli/list_keybinds.zig | 2 +- src/cli/list_themes.zig | 2 +- src/cli/show_config.zig | 2 +- src/cli/show_face.zig | 2 +- src/cli/ssh_cache.zig | 2 +- src/cli/validate_config.zig | 2 +- src/global.zig | 7 +- src/helpgen.zig | 2 +- 22 files changed, 549 insertions(+), 298 deletions(-) create mode 100644 src/cli/ghostty.zig diff --git a/src/build/bash_completions.zig b/src/build/bash_completions.zig index ad62ff97d..536cadbc4 100644 --- a/src/build/bash_completions.zig +++ b/src/build/bash_completions.zig @@ -1,7 +1,7 @@ const std = @import("std"); const Config = @import("../config/Config.zig"); -const Action = @import("../cli/action.zig").Action; +const Action = @import("../cli.zig").ghostty.Action; /// A bash completions configuration that contains all the available commands /// and options. diff --git a/src/build/fish_completions.zig b/src/build/fish_completions.zig index 2b2563ee7..0b6c45e1f 100644 --- a/src/build/fish_completions.zig +++ b/src/build/fish_completions.zig @@ -1,7 +1,7 @@ const std = @import("std"); const Config = @import("../config/Config.zig"); -const Action = @import("../cli/action.zig").Action; +const Action = @import("../cli.zig").ghostty.Action; /// A fish completions configuration that contains all the available commands /// and options. diff --git a/src/build/mdgen/mdgen.zig b/src/build/mdgen/mdgen.zig index e7d966323..53ed02067 100644 --- a/src/build/mdgen/mdgen.zig +++ b/src/build/mdgen/mdgen.zig @@ -2,7 +2,7 @@ const std = @import("std"); const help_strings = @import("help_strings"); const build_config = @import("../../build_config.zig"); const Config = @import("../../config/Config.zig"); -const Action = @import("../../cli/action.zig").Action; +const Action = @import("../../cli/ghostty.zig").Action; const KeybindAction = @import("../../input/Binding.zig").Action; pub fn substitute(alloc: std.mem.Allocator, input: []const u8, writer: anytype) !void { diff --git a/src/build/zsh_completions.zig b/src/build/zsh_completions.zig index 2ded6d73c..6bddcd285 100644 --- a/src/build/zsh_completions.zig +++ b/src/build/zsh_completions.zig @@ -1,7 +1,7 @@ const std = @import("std"); const Config = @import("../config/Config.zig"); -const Action = @import("../cli/action.zig").Action; +const Action = @import("../cli.zig").ghostty.Action; /// A zsh completions configuration that contains all the available commands /// and options. diff --git a/src/cli.zig b/src/cli.zig index 151e6e648..008ff1ebf 100644 --- a/src/cli.zig +++ b/src/cli.zig @@ -1,7 +1,8 @@ const diags = @import("cli/diagnostics.zig"); pub const args = @import("cli/args.zig"); -pub const Action = @import("cli/action.zig").Action; +pub const action = @import("cli/action.zig"); +pub const ghostty = @import("cli/ghostty.zig"); pub const CompatibilityHandler = args.CompatibilityHandler; pub const compatibilityRenamed = args.compatibilityRenamed; pub const DiagnosticList = diags.DiagnosticList; diff --git a/src/cli/action.zig b/src/cli/action.zig index 728f36efe..41173a9f1 100644 --- a/src/cli/action.zig +++ b/src/cli/action.zig @@ -1,320 +1,277 @@ const std = @import("std"); const Allocator = std.mem.Allocator; -const help_strings = @import("help_strings"); -const list_fonts = @import("list_fonts.zig"); -const help = @import("help.zig"); -const version = @import("version.zig"); -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 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"); -const crash_report = @import("crash_report.zig"); -const show_face = @import("show_face.zig"); -const boo = @import("boo.zig"); +pub const DetectError = error{ + /// Multiple actions were detected. You can specify at most one + /// action on the CLI otherwise the behavior desired is ambiguous. + MultipleActions, -/// Special commands that can be invoked via CLI flags. These are all -/// invoked by using `+` as a CLI flag. The only exception is -/// "version" which can be invoked additionally with `--version`. -pub const Action = enum { - /// Output the version and exit - version, - - /// Output help information for the CLI or configuration - help, - - /// List available fonts - @"list-fonts", - - /// List available keybinds - @"list-keybinds", - - /// List available themes - @"list-themes", - - /// List named RGB colors - @"list-colors", - - /// List keybind actions - @"list-actions", - - /// Manage SSH terminfo cache for automatic remote host setup - @"ssh-cache", - - /// Edit the config file in the configured terminal editor. - @"edit-config", - - /// Dump the config to stdout - @"show-config", - - // Validate passed config file - @"validate-config", - - // Show which font face Ghostty loads a codepoint from. - @"show-face", - - // List, (eventually) view, and (eventually) send crash reports. - @"crash-report", - - // Boo! - boo, - - pub const Error = error{ - /// Multiple actions were detected. You can specify at most one - /// action on the CLI otherwise the behavior desired is ambiguous. - MultipleActions, - - /// An unknown action was specified. - InvalidAction, - }; - - /// This should be returned by actions that want to print the help text. - pub const help_error = error.ActionHelpRequested; - - /// Detect the action from CLI args. - pub fn detectCLI(alloc: Allocator) !?Action { - var iter = try std.process.argsWithAllocator(alloc); - defer iter.deinit(); - return try detectIter(&iter); - } - - /// Detect the action from any iterator, used primarily for tests. - pub fn detectIter(iter: anytype) Error!?Action { - var pending_help: bool = false; - var pending: ?Action = null; - while (iter.next()) |arg| { - // If we see a "-e" and we haven't seen a command yet, then - // we are done looking for commands. This special case enables - // `ghostty -e ghostty +command`. If we've seen a command we - // still want to keep looking because - // `ghostty +command -e +command` is invalid. - if (std.mem.eql(u8, arg, "-e") and pending == null) return null; - - // Special case, --version always outputs the version no - // matter what, no matter what other args exist. - if (std.mem.eql(u8, arg, "--version")) return .version; - - // --help matches "help" but if a subcommand is specified - // then we match the subcommand. - if (std.mem.eql(u8, arg, "--help") or std.mem.eql(u8, arg, "-h")) { - pending_help = true; - continue; - } - - // Commands must start with "+" - if (arg.len == 0 or arg[0] != '+') continue; - if (pending != null) return Error.MultipleActions; - pending = std.meta.stringToEnum(Action, arg[1..]) orelse return Error.InvalidAction; - } - - // If we have an action, we always return that action, even if we've - // seen "--help" or "-h" because the action may have its own help text. - if (pending != null) return pending; - - // If we've seen "--help" or "-h" then we return the help action. - if (pending_help) return .help; - - return pending; - } - - /// Run the action. This returns the exit code to exit with. - pub fn run(self: Action, alloc: Allocator) !u8 { - return self.runMain(alloc) catch |err| switch (err) { - // If help is requested, then we use some comptime trickery - // to find this action in the help strings and output that. - help_error => err: { - inline for (@typeInfo(Action).@"enum".fields) |field| { - // Future note: for now we just output the help text directly - // to stdout. In the future we can style this much prettier - // for all commands by just changing this one place. - - if (std.mem.eql(u8, field.name, @tagName(self))) { - const stdout = std.io.getStdOut().writer(); - const text = @field(help_strings.Action, field.name) ++ "\n"; - stdout.writeAll(text) catch |write_err| { - std.log.warn("failed to write help text: {}\n", .{write_err}); - break :err 1; - }; - - break :err 0; - } - } - - break :err err; - }, - else => err, - }; - } - - fn runMain(self: Action, alloc: Allocator) !u8 { - return switch (self) { - .version => try version.run(alloc), - .help => try help.run(alloc), - .@"list-fonts" => try list_fonts.run(alloc), - .@"list-keybinds" => try list_keybinds.run(alloc), - .@"list-themes" => try list_themes.run(alloc), - .@"list-colors" => try list_colors.run(alloc), - .@"list-actions" => try list_actions.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), - .@"crash-report" => try crash_report.run(alloc), - .@"show-face" => try show_face.run(alloc), - .boo => try boo.run(alloc), - }; - } - - /// Returns the filename associated with an action. This is a relative - /// path from the root src/ directory. - pub fn file(comptime self: Action) []const u8 { - comptime { - const filename = filename: { - const tag = @tagName(self); - var filename: [tag.len]u8 = undefined; - _ = std.mem.replace(u8, tag, "-", "_", &filename); - break :filename &filename; - }; - - return "cli/" ++ filename ++ ".zig"; - } - } - - /// Returns the options of action. Supports generating shell completions - /// without duplicating the mapping from Action to relevant Option - /// @import(..) declaration. - pub fn options(comptime self: Action) type { - comptime { - return switch (self) { - .version => version.Options, - .help => help.Options, - .@"list-fonts" => list_fonts.Options, - .@"list-keybinds" => list_keybinds.Options, - .@"list-themes" => list_themes.Options, - .@"list-colors" => list_colors.Options, - .@"list-actions" => list_actions.Options, - .@"ssh-cache" => ssh_cache.Options, - .@"edit-config" => edit_config.Options, - .@"show-config" => show_config.Options, - .@"validate-config" => validate_config.Options, - .@"crash-report" => crash_report.Options, - .@"show-face" => show_face.Options, - .boo => boo.Options, - }; - } - } + /// An unknown action was specified. + InvalidAction, }; -test "parse action none" { +/// Detect the action from CLI args. +pub fn detectArgs(comptime E: type, alloc: Allocator) !?E { + var iter = try std.process.argsWithAllocator(alloc); + defer iter.deinit(); + return try detectIter(E, &iter); +} + +/// Detect the action from any iterator. Each iterator value should yield +/// a CLI argument such as "--foo". +/// +/// The comptime type E must be an enum with the available actions. +/// If the type E has a decl `detectSpecialCase`, then it will be called +/// for each argument to allow handling of special cases. The function +/// signature for `detectSpecialCase` should be: +/// +/// fn detectSpecialCase(arg: []const u8) ?SpecialCase(E) +/// +pub fn detectIter( + comptime E: type, + iter: anytype, +) DetectError!?E { + var fallback: ?E = null; + var pending: ?E = null; + while (iter.next()) |arg| { + // Allow handling of special cases. + if (@hasDecl(E, "detectSpecialCase")) special: { + const special = E.detectSpecialCase(arg) orelse break :special; + switch (special) { + .action => |a| return a, + .fallback => |a| fallback = a, + .abort_if_no_action => if (pending == null) return null, + } + } + + // Commands must start with "+" + if (arg.len == 0 or arg[0] != '+') continue; + if (pending != null) return DetectError.MultipleActions; + pending = std.meta.stringToEnum(E, arg[1..]) orelse + return DetectError.InvalidAction; + } + + // If we have an action, we always return that action, even if we've + // seen "--help" or "-h" because the action may have its own help text. + if (pending != null) return pending; + + // If we have no action but we have a fallback, then we return that. + if (fallback) |a| return a; + + return null; +} + +/// The action enum E can implement the decl `detectSpecialCase` to +/// return this enum in order to perform various special case actions. +pub fn SpecialCase(comptime E: type) type { + return union(enum) { + /// Immediately return this action. + action: E, + + /// Return this action if no other action is found. + fallback: E, + + /// If there is no pending action (we haven't seen an action yet) + /// then we should return no action. This is kind of weird but is + /// a special case to allow "-e" in Ghostty. + abort_if_no_action, + }; +} + +test "detect direct match" { const testing = std.testing; const alloc = testing.allocator; + const Enum = enum { foo, bar, baz }; var iter = try std.process.ArgIteratorGeneral(.{}).init( alloc, - "--a=42 --b --b-f=false", + "+foo", ); defer iter.deinit(); - const action = try Action.detectIter(&iter); - try testing.expect(action == null); + const result = try detectIter(Enum, &iter); + try testing.expectEqual(Enum.foo, result.?); } -test "parse action version" { +test "detect invalid match" { const testing = std.testing; const alloc = testing.allocator; + const Enum = enum { foo, bar, baz }; - { - var iter = try std.process.ArgIteratorGeneral(.{}).init( - alloc, - "--a=42 --b --b-f=false --version", - ); - defer iter.deinit(); - const action = try Action.detectIter(&iter); - try testing.expect(action.? == .version); - } - - { - var iter = try std.process.ArgIteratorGeneral(.{}).init( - alloc, - "--version --a=42 --b --b-f=false", - ); - defer iter.deinit(); - const action = try Action.detectIter(&iter); - try testing.expect(action.? == .version); - } - - { - var iter = try std.process.ArgIteratorGeneral(.{}).init( - alloc, - "--c=84 --d --version --a=42 --b --b-f=false", - ); - defer iter.deinit(); - const action = try Action.detectIter(&iter); - try testing.expect(action.? == .version); - } + var iter = try std.process.ArgIteratorGeneral(.{}).init( + alloc, + "+invalid", + ); + defer iter.deinit(); + try testing.expectError( + DetectError.InvalidAction, + detectIter(Enum, &iter), + ); } -test "parse action plus" { +test "detect multiple actions" { const testing = std.testing; const alloc = testing.allocator; + const Enum = enum { foo, bar, baz }; - { - var iter = try std.process.ArgIteratorGeneral(.{}).init( - alloc, - "--a=42 --b --b-f=false +version", - ); - defer iter.deinit(); - const action = try Action.detectIter(&iter); - try testing.expect(action.? == .version); - } - - { - var iter = try std.process.ArgIteratorGeneral(.{}).init( - alloc, - "+version --a=42 --b --b-f=false", - ); - defer iter.deinit(); - const action = try Action.detectIter(&iter); - try testing.expect(action.? == .version); - } - - { - var iter = try std.process.ArgIteratorGeneral(.{}).init( - alloc, - "--c=84 --d +version --a=42 --b --b-f=false", - ); - defer iter.deinit(); - const action = try Action.detectIter(&iter); - try testing.expect(action.? == .version); - } + var iter = try std.process.ArgIteratorGeneral(.{}).init( + alloc, + "+foo +bar", + ); + defer iter.deinit(); + try testing.expectError( + DetectError.MultipleActions, + detectIter(Enum, &iter), + ); } -test "parse action plus ignores -e" { +test "detect no match" { const testing = std.testing; const alloc = testing.allocator; + const Enum = enum { foo, bar, baz }; + + var iter = try std.process.ArgIteratorGeneral(.{}).init( + alloc, + "--some-flag", + ); + defer iter.deinit(); + const result = try detectIter(Enum, &iter); + try testing.expect(result == null); +} + +test "detect special case action" { + const testing = std.testing; + const alloc = testing.allocator; + const Enum = enum { + foo, + bar, + + fn detectSpecialCase(arg: []const u8) ?SpecialCase(@This()) { + return if (std.mem.eql(u8, arg, "--special")) + .{ .action = .foo } + else + null; + } + }; { var iter = try std.process.ArgIteratorGeneral(.{}).init( alloc, - "--a=42 -e +version", + "--special +bar", ); defer iter.deinit(); - const action = try Action.detectIter(&iter); - try testing.expect(action == null); + const result = try detectIter(Enum, &iter); + try testing.expectEqual(Enum.foo, result.?); } { var iter = try std.process.ArgIteratorGeneral(.{}).init( alloc, - "+list-fonts --a=42 -e +version", + "+bar --special", ); defer iter.deinit(); - try testing.expectError( - Action.Error.MultipleActions, - Action.detectIter(&iter), + const result = try detectIter(Enum, &iter); + try testing.expectEqual(Enum.foo, result.?); + } + + { + var iter = try std.process.ArgIteratorGeneral(.{}).init( + alloc, + "+bar", ); + defer iter.deinit(); + const result = try detectIter(Enum, &iter); + try testing.expectEqual(Enum.bar, result.?); + } +} + +test "detect special case fallback" { + const testing = std.testing; + const alloc = testing.allocator; + const Enum = enum { + foo, + bar, + + fn detectSpecialCase(arg: []const u8) ?SpecialCase(@This()) { + return if (std.mem.eql(u8, arg, "--special")) + .{ .fallback = .foo } + else + null; + } + }; + + { + var iter = try std.process.ArgIteratorGeneral(.{}).init( + alloc, + "--special", + ); + defer iter.deinit(); + const result = try detectIter(Enum, &iter); + try testing.expectEqual(Enum.foo, result.?); + } + + { + var iter = try std.process.ArgIteratorGeneral(.{}).init( + alloc, + "+bar --special", + ); + defer iter.deinit(); + const result = try detectIter(Enum, &iter); + try testing.expectEqual(Enum.bar, result.?); + } + + { + var iter = try std.process.ArgIteratorGeneral(.{}).init( + alloc, + "--special +bar", + ); + defer iter.deinit(); + const result = try detectIter(Enum, &iter); + try testing.expectEqual(Enum.bar, result.?); + } +} + +test "detect special case abort_if_no_action" { + const testing = std.testing; + const alloc = testing.allocator; + const Enum = enum { + foo, + bar, + + fn detectSpecialCase(arg: []const u8) ?SpecialCase(@This()) { + return if (std.mem.eql(u8, arg, "-e")) + .abort_if_no_action + else + null; + } + }; + + { + var iter = try std.process.ArgIteratorGeneral(.{}).init( + alloc, + "-e", + ); + defer iter.deinit(); + const result = try detectIter(Enum, &iter); + try testing.expect(result == null); + } + + { + var iter = try std.process.ArgIteratorGeneral(.{}).init( + alloc, + "+foo -e", + ); + defer iter.deinit(); + const result = try detectIter(Enum, &iter); + try testing.expectEqual(Enum.foo, result.?); + } + + { + var iter = try std.process.ArgIteratorGeneral(.{}).init( + alloc, + "-e +bar", + ); + defer iter.deinit(); + const result = try detectIter(Enum, &iter); + try testing.expect(result == null); } } diff --git a/src/cli/boo.zig b/src/cli/boo.zig index 47c8ab741..72b282ef6 100644 --- a/src/cli/boo.zig +++ b/src/cli/boo.zig @@ -1,7 +1,7 @@ const std = @import("std"); const builtin = @import("builtin"); const args = @import("args.zig"); -const Action = @import("action.zig").Action; +const Action = @import("ghostty.zig").Action; const Allocator = std.mem.Allocator; const help_strings = @import("help_strings"); const vaxis = @import("vaxis"); diff --git a/src/cli/crash_report.zig b/src/cli/crash_report.zig index ff8509797..c6a383563 100644 --- a/src/cli/crash_report.zig +++ b/src/cli/crash_report.zig @@ -1,7 +1,7 @@ const std = @import("std"); const Allocator = std.mem.Allocator; const args = @import("args.zig"); -const Action = @import("action.zig").Action; +const Action = @import("ghostty.zig").Action; const Config = @import("../config.zig").Config; const crash = @import("../crash/main.zig"); diff --git a/src/cli/edit_config.zig b/src/cli/edit_config.zig index 3be88e090..dd09d7e2f 100644 --- a/src/cli/edit_config.zig +++ b/src/cli/edit_config.zig @@ -3,7 +3,7 @@ const builtin = @import("builtin"); const assert = std.debug.assert; const args = @import("args.zig"); const Allocator = std.mem.Allocator; -const Action = @import("action.zig").Action; +const Action = @import("ghostty.zig").Action; const configpkg = @import("../config.zig"); const internal_os = @import("../os/main.zig"); const Config = configpkg.Config; diff --git a/src/cli/ghostty.zig b/src/cli/ghostty.zig new file mode 100644 index 000000000..c1b661f70 --- /dev/null +++ b/src/cli/ghostty.zig @@ -0,0 +1,290 @@ +const std = @import("std"); +const Allocator = std.mem.Allocator; +const help_strings = @import("help_strings"); +const actionpkg = @import("action.zig"); +const SpecialCase = actionpkg.SpecialCase; + +const list_fonts = @import("list_fonts.zig"); +const help = @import("help.zig"); +const version = @import("version.zig"); +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 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"); +const crash_report = @import("crash_report.zig"); +const show_face = @import("show_face.zig"); +const boo = @import("boo.zig"); + +/// Special commands that can be invoked via CLI flags. These are all +/// invoked by using `+` as a CLI flag. The only exception is +/// "version" which can be invoked additionally with `--version`. +pub const Action = enum { + /// Output the version and exit + version, + + /// Output help information for the CLI or configuration + help, + + /// List available fonts + @"list-fonts", + + /// List available keybinds + @"list-keybinds", + + /// List available themes + @"list-themes", + + /// List named RGB colors + @"list-colors", + + /// List keybind actions + @"list-actions", + + /// Manage SSH terminfo cache for automatic remote host setup + @"ssh-cache", + + /// Edit the config file in the configured terminal editor. + @"edit-config", + + /// Dump the config to stdout + @"show-config", + + // Validate passed config file + @"validate-config", + + // Show which font face Ghostty loads a codepoint from. + @"show-face", + + // List, (eventually) view, and (eventually) send crash reports. + @"crash-report", + + // Boo! + boo, + + pub fn detectSpecialCase(arg: []const u8) ?SpecialCase(Action) { + // If we see a "-e" and we haven't seen a command yet, then + // we are done looking for commands. This special case enables + // `ghostty -e ghostty +command`. If we've seen a command we + // still want to keep looking because + // `ghostty +command -e +command` is invalid. + if (std.mem.eql(u8, arg, "-e")) return .abort_if_no_action; + + // Special case, --version always outputs the version no + // matter what, no matter what other args exist. + if (std.mem.eql(u8, arg, "--version")) { + return .{ .action = .version }; + } + + // --help matches "help" but if a subcommand is specified + // then we match the subcommand. + if (std.mem.eql(u8, arg, "--help") or std.mem.eql(u8, arg, "-h")) { + return .{ .fallback = .help }; + } + + return null; + } + + /// This should be returned by actions that want to print the help text. + pub const help_error = error.ActionHelpRequested; + + /// Run the action. This returns the exit code to exit with. + pub fn run(self: Action, alloc: Allocator) !u8 { + return self.runMain(alloc) catch |err| switch (err) { + // If help is requested, then we use some comptime trickery + // to find this action in the help strings and output that. + help_error => err: { + inline for (@typeInfo(Action).@"enum".fields) |field| { + // Future note: for now we just output the help text directly + // to stdout. In the future we can style this much prettier + // for all commands by just changing this one place. + + if (std.mem.eql(u8, field.name, @tagName(self))) { + const stdout = std.io.getStdOut().writer(); + const text = @field(help_strings.Action, field.name) ++ "\n"; + stdout.writeAll(text) catch |write_err| { + std.log.warn("failed to write help text: {}\n", .{write_err}); + break :err 1; + }; + + break :err 0; + } + } + + break :err err; + }, + else => err, + }; + } + + fn runMain(self: Action, alloc: Allocator) !u8 { + return switch (self) { + .version => try version.run(alloc), + .help => try help.run(alloc), + .@"list-fonts" => try list_fonts.run(alloc), + .@"list-keybinds" => try list_keybinds.run(alloc), + .@"list-themes" => try list_themes.run(alloc), + .@"list-colors" => try list_colors.run(alloc), + .@"list-actions" => try list_actions.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), + .@"crash-report" => try crash_report.run(alloc), + .@"show-face" => try show_face.run(alloc), + .boo => try boo.run(alloc), + }; + } + + /// Returns the filename associated with an action. This is a relative + /// path from the root src/ directory. + pub fn file(comptime self: Action) []const u8 { + comptime { + const filename = filename: { + const tag = @tagName(self); + var filename: [tag.len]u8 = undefined; + _ = std.mem.replace(u8, tag, "-", "_", &filename); + break :filename &filename; + }; + + return "cli/" ++ filename ++ ".zig"; + } + } + + /// Returns the options of action. Supports generating shell completions + /// without duplicating the mapping from Action to relevant Option + /// @import(..) declaration. + pub fn options(comptime self: Action) type { + comptime { + return switch (self) { + .version => version.Options, + .help => help.Options, + .@"list-fonts" => list_fonts.Options, + .@"list-keybinds" => list_keybinds.Options, + .@"list-themes" => list_themes.Options, + .@"list-colors" => list_colors.Options, + .@"list-actions" => list_actions.Options, + .@"ssh-cache" => ssh_cache.Options, + .@"edit-config" => edit_config.Options, + .@"show-config" => show_config.Options, + .@"validate-config" => validate_config.Options, + .@"crash-report" => crash_report.Options, + .@"show-face" => show_face.Options, + .boo => boo.Options, + }; + } + } +}; + +test "parse action none" { + const testing = std.testing; + const alloc = testing.allocator; + + var iter = try std.process.ArgIteratorGeneral(.{}).init( + alloc, + "--a=42 --b --b-f=false", + ); + defer iter.deinit(); + const action = try actionpkg.detectIter(Action, &iter); + try testing.expect(action == null); +} + +test "parse action version" { + const testing = std.testing; + const alloc = testing.allocator; + + { + var iter = try std.process.ArgIteratorGeneral(.{}).init( + alloc, + "--a=42 --b --b-f=false --version", + ); + defer iter.deinit(); + const action = try actionpkg.detectIter(Action, &iter); + try testing.expect(action.? == .version); + } + + { + var iter = try std.process.ArgIteratorGeneral(.{}).init( + alloc, + "--version --a=42 --b --b-f=false", + ); + defer iter.deinit(); + const action = try actionpkg.detectIter(Action, &iter); + try testing.expect(action.? == .version); + } + + { + var iter = try std.process.ArgIteratorGeneral(.{}).init( + alloc, + "--c=84 --d --version --a=42 --b --b-f=false", + ); + defer iter.deinit(); + const action = try actionpkg.detectIter(Action, &iter); + try testing.expect(action.? == .version); + } +} + +test "parse action plus" { + const testing = std.testing; + const alloc = testing.allocator; + + { + var iter = try std.process.ArgIteratorGeneral(.{}).init( + alloc, + "--a=42 --b --b-f=false +version", + ); + defer iter.deinit(); + const action = try actionpkg.detectIter(Action, &iter); + try testing.expect(action.? == .version); + } + + { + var iter = try std.process.ArgIteratorGeneral(.{}).init( + alloc, + "+version --a=42 --b --b-f=false", + ); + defer iter.deinit(); + const action = try actionpkg.detectIter(Action, &iter); + try testing.expect(action.? == .version); + } + + { + var iter = try std.process.ArgIteratorGeneral(.{}).init( + alloc, + "--c=84 --d +version --a=42 --b --b-f=false", + ); + defer iter.deinit(); + const action = try actionpkg.detectIter(Action, &iter); + try testing.expect(action.? == .version); + } +} + +test "parse action plus ignores -e" { + const testing = std.testing; + const alloc = testing.allocator; + + { + var iter = try std.process.ArgIteratorGeneral(.{}).init( + alloc, + "--a=42 -e +version", + ); + defer iter.deinit(); + const action = try actionpkg.detectIter(Action, &iter); + try testing.expect(action == null); + } + + { + var iter = try std.process.ArgIteratorGeneral(.{}).init( + alloc, + "+list-fonts --a=42 -e +version", + ); + defer iter.deinit(); + try testing.expectError( + actionpkg.DetectError.MultipleActions, + actionpkg.detectIter(Action, &iter), + ); + } +} diff --git a/src/cli/help.zig b/src/cli/help.zig index 6c989fd0c..0528dc1c2 100644 --- a/src/cli/help.zig +++ b/src/cli/help.zig @@ -1,7 +1,7 @@ const std = @import("std"); const Allocator = std.mem.Allocator; const args = @import("args.zig"); -const Action = @import("action.zig").Action; +const Action = @import("ghostty.zig").Action; // Note that this options struct doesn't implement the `help` decl like other // actions. That is because the help command is special and wants to handle its diff --git a/src/cli/list_actions.zig b/src/cli/list_actions.zig index 1d17873cc..6f5ce06a2 100644 --- a/src/cli/list_actions.zig +++ b/src/cli/list_actions.zig @@ -1,6 +1,6 @@ const std = @import("std"); const args = @import("args.zig"); -const Action = @import("action.zig").Action; +const Action = @import("ghostty.zig").Action; const Allocator = std.mem.Allocator; const helpgen_actions = @import("../input/helpgen_actions.zig"); diff --git a/src/cli/list_colors.zig b/src/cli/list_colors.zig index bfe17df7c..e43a43c86 100644 --- a/src/cli/list_colors.zig +++ b/src/cli/list_colors.zig @@ -1,5 +1,5 @@ const std = @import("std"); -const Action = @import("action.zig").Action; +const Action = @import("ghostty.zig").Action; const args = @import("args.zig"); const x11_color = @import("../terminal/main.zig").x11_color; diff --git a/src/cli/list_fonts.zig b/src/cli/list_fonts.zig index e8a010ecd..58246d3ad 100644 --- a/src/cli/list_fonts.zig +++ b/src/cli/list_fonts.zig @@ -1,7 +1,7 @@ const std = @import("std"); const Allocator = std.mem.Allocator; const ArenaAllocator = std.heap.ArenaAllocator; -const Action = @import("action.zig").Action; +const Action = @import("ghostty.zig").Action; const args = @import("args.zig"); const font = @import("../font/main.zig"); diff --git a/src/cli/list_keybinds.zig b/src/cli/list_keybinds.zig index f84d540c3..94f445eea 100644 --- a/src/cli/list_keybinds.zig +++ b/src/cli/list_keybinds.zig @@ -1,7 +1,7 @@ const std = @import("std"); const builtin = @import("builtin"); const args = @import("args.zig"); -const Action = @import("action.zig").Action; +const Action = @import("ghostty.zig").Action; const Arena = std.heap.ArenaAllocator; const Allocator = std.mem.Allocator; const configpkg = @import("../config.zig"); diff --git a/src/cli/list_themes.zig b/src/cli/list_themes.zig index e80a92286..b85f98445 100644 --- a/src/cli/list_themes.zig +++ b/src/cli/list_themes.zig @@ -1,7 +1,7 @@ const std = @import("std"); const inputpkg = @import("../input.zig"); const args = @import("args.zig"); -const Action = @import("action.zig").Action; +const Action = @import("ghostty.zig").Action; const Config = @import("../config/Config.zig"); const themepkg = @import("../config/theme.zig"); const tui = @import("tui.zig"); diff --git a/src/cli/show_config.zig b/src/cli/show_config.zig index cbcd2486d..3f22c75c2 100644 --- a/src/cli/show_config.zig +++ b/src/cli/show_config.zig @@ -1,7 +1,7 @@ const std = @import("std"); const args = @import("args.zig"); const Allocator = std.mem.Allocator; -const Action = @import("action.zig").Action; +const Action = @import("ghostty.zig").Action; const configpkg = @import("../config.zig"); const Config = configpkg.Config; diff --git a/src/cli/show_face.zig b/src/cli/show_face.zig index b7f039dc8..e3b596bcd 100644 --- a/src/cli/show_face.zig +++ b/src/cli/show_face.zig @@ -1,7 +1,7 @@ const std = @import("std"); const Allocator = std.mem.Allocator; const ArenaAllocator = std.heap.ArenaAllocator; -const Action = @import("action.zig").Action; +const Action = @import("ghostty.zig").Action; const args = @import("args.zig"); const diagnostics = @import("diagnostics.zig"); const font = @import("../font/main.zig"); diff --git a/src/cli/ssh_cache.zig b/src/cli/ssh_cache.zig index c8e2e1123..1099f0112 100644 --- a/src/cli/ssh_cache.zig +++ b/src/cli/ssh_cache.zig @@ -3,7 +3,7 @@ const fs = std.fs; const Allocator = std.mem.Allocator; const xdg = @import("../os/xdg.zig"); const args = @import("args.zig"); -const Action = @import("action.zig").Action; +const Action = @import("ghostty.zig").Action; pub const Entry = @import("ssh-cache/Entry.zig"); pub const DiskCache = @import("ssh-cache/DiskCache.zig"); diff --git a/src/cli/validate_config.zig b/src/cli/validate_config.zig index 5bc6ff406..114843e9a 100644 --- a/src/cli/validate_config.zig +++ b/src/cli/validate_config.zig @@ -1,7 +1,7 @@ const std = @import("std"); const Allocator = std.mem.Allocator; const args = @import("args.zig"); -const Action = @import("action.zig").Action; +const Action = @import("ghostty.zig").Action; const Config = @import("../config.zig").Config; const cli = @import("../cli.zig"); diff --git a/src/global.zig b/src/global.zig index 668d2faec..e68ec7f74 100644 --- a/src/global.zig +++ b/src/global.zig @@ -30,7 +30,7 @@ pub const GlobalState = struct { gpa: ?GPA, alloc: std.mem.Allocator, - action: ?cli.Action, + action: ?cli.ghostty.Action, logging: Logging, rlimits: ResourceLimits = .{}, @@ -92,7 +92,10 @@ pub const GlobalState = struct { unreachable; // We first try to parse any action that we may be executing. - self.action = try cli.Action.detectCLI(self.alloc); + self.action = try cli.action.detectArgs( + cli.ghostty.Action, + self.alloc, + ); // If we have an action executing, we disable logging by default // since we write to stderr we don't want logs messing up our diff --git a/src/helpgen.zig b/src/helpgen.zig index 560e5ce29..e1628c218 100644 --- a/src/helpgen.zig +++ b/src/helpgen.zig @@ -4,7 +4,7 @@ const std = @import("std"); const Config = @import("config/Config.zig"); -const Action = @import("cli/action.zig").Action; +const Action = @import("cli.zig").ghostty.Action; const KeybindAction = @import("input/Binding.zig").Action; pub fn main() !void { From b8f5cf9d52506add7d30cacc2142a79949f76ea3 Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Tue, 8 Jul 2025 09:12:03 -0700 Subject: [PATCH 68/94] initial `ghostty-bench` program --- src/benchmark/TerminalStream.zig | 56 ++++++++++++++++++++++++++--- src/benchmark/cli.zig | 61 ++++++++++++++++++++++++++++++++ src/benchmark/main.zig | 1 + src/build/GhosttyBench.zig | 16 +++++++++ src/main_bench.zig | 3 ++ 5 files changed, 133 insertions(+), 4 deletions(-) create mode 100644 src/benchmark/cli.zig create mode 100644 src/main_bench.zig diff --git a/src/benchmark/TerminalStream.zig b/src/benchmark/TerminalStream.zig index 8580695f2..6300ba04c 100644 --- a/src/benchmark/TerminalStream.zig +++ b/src/benchmark/TerminalStream.zig @@ -14,36 +14,49 @@ const TerminalStream = @This(); const std = @import("std"); +const assert = std.debug.assert; const Allocator = std.mem.Allocator; const terminalpkg = @import("../terminal/main.zig"); const Benchmark = @import("Benchmark.zig"); const Terminal = terminalpkg.Terminal; const Stream = terminalpkg.Stream(*Handler); +opts: Options, terminal: Terminal, handler: Handler, stream: Stream, +/// The file, opened in the setup function. +data_f: ?std.fs.File = null, + pub const Options = struct { /// The size of the terminal. This affects benchmarking when /// dealing with soft line wrapping and the memory impact /// of page sizes. @"terminal-rows": u16 = 80, @"terminal-cols": u16 = 120, + + /// The data to read as a filepath. If this is "-" then + /// we will read stdin. If this is unset, then we will + /// do nothing (benchmark is a noop). It'd be more unixy to + /// use stdin by default but I find that a hanging CLI command + /// with no interaction is a bit annoying. + data: ?[]const u8 = null, }; /// Create a new terminal stream handler for the given arguments. pub fn create( alloc: Allocator, - args: Options, + opts: Options, ) !*TerminalStream { const ptr = try alloc.create(TerminalStream); errdefer alloc.destroy(ptr); ptr.* = .{ + .opts = opts, .terminal = try .init(alloc, .{ - .rows = args.@"terminal-rows", - .cols = args.@"terminal-cols", + .rows = opts.@"terminal-rows", + .cols = opts.@"terminal-cols", }), .handler = .{ .t = &ptr.terminal }, .stream = .{ .handler = &ptr.handler }, @@ -61,17 +74,52 @@ pub fn benchmark(self: *TerminalStream) Benchmark { return .init(self, .{ .stepFn = step, .setupFn = setup, + .teardownFn = teardown, }); } fn setup(ptr: *anyopaque) Benchmark.Error!void { const self: *TerminalStream = @ptrCast(@alignCast(ptr)); + + // Always reset our terminal state self.terminal.fullReset(); + + // Open our data file to prepare for reading. We can do more + // validation here eventually. + assert(self.data_f == null); + if (self.opts.data) |path| { + self.data_f = std.fs.cwd().openFile(path, .{}) catch + return error.BenchmarkFailed; + } +} + +fn teardown(ptr: *anyopaque) void { + const self: *TerminalStream = @ptrCast(@alignCast(ptr)); + if (self.data_f) |f| { + f.close(); + self.data_f = null; + } } fn step(ptr: *anyopaque) Benchmark.Error!void { const self: *TerminalStream = @ptrCast(@alignCast(ptr)); - _ = self; + + // Get our buffered reader so we're not predominantly + // waiting on file IO. It'd be better to move this fully into + // memory. If we're IO bound though that should show up on + // the benchmark results and... I know writing this that we + // aren't currently IO bound. + const f = self.data_f orelse return; + var r = std.io.bufferedReader(f.reader()); + + var buf: [4096]u8 = undefined; + while (true) { + const n = r.read(&buf) catch return error.BenchmarkFailed; + if (n == 0) break; // EOF reached + const chunk = buf[0..n]; + self.stream.nextSlice(chunk) catch + return error.BenchmarkFailed; + } } /// Implements the handler interface for the terminal.Stream. diff --git a/src/benchmark/cli.zig b/src/benchmark/cli.zig new file mode 100644 index 000000000..781eafd24 --- /dev/null +++ b/src/benchmark/cli.zig @@ -0,0 +1,61 @@ +const std = @import("std"); +const Allocator = std.mem.Allocator; +const cli = @import("../cli.zig"); + +/// The available actions for the CLI. This is the list of available +/// benchmarks. +const Action = enum { + @"terminal-stream", + + /// Returns the struct associated with the action. The struct + /// should have a few decls: + /// + /// - `const Options`: The CLI options for the action. + /// - `fn create`: Create a new instance of the action from options. + /// - `fn benchmark`: Returns a `Benchmark` instance for the action. + /// + /// See TerminalStream for an example. + pub fn Struct(comptime action: Action) type { + return switch (action) { + .@"terminal-stream" => @import("TerminalStream.zig"), + }; + } +}; + +/// An entrypoint for the benchmark CLI. +pub fn main() !void { + // TODO: Better terminal output throughout this, use libvaxis. + + const alloc = std.heap.c_allocator; + const action_ = try cli.action.detectArgs(Action, alloc); + const action = action_ orelse return error.NoAction; + + // We need a comptime action to get the struct type and do the + // rest. + return switch (action) { + inline else => |comptime_action| { + const BenchmarkImpl = Action.Struct(comptime_action); + try mainAction(BenchmarkImpl, alloc); + }, + }; +} + +fn mainAction(comptime BenchmarkImpl: type, alloc: Allocator) !void { + // First, parse our CLI options. + const Options = BenchmarkImpl.Options; + var opts: Options = .{}; + defer if (@hasDecl(Options, "deinit")) opts.deinit(); + { + var iter = try cli.args.argsIterator(alloc); + defer iter.deinit(); + try cli.args.parse(Options, alloc, &opts, &iter); + } + + // Create our implementation + const impl = try BenchmarkImpl.create(alloc, opts); + defer impl.destroy(alloc); + + // Initialize our benchmark + const b = impl.benchmark(); + _ = try b.run(.once); +} diff --git a/src/benchmark/main.zig b/src/benchmark/main.zig index 802519e29..93e489578 100644 --- a/src/benchmark/main.zig +++ b/src/benchmark/main.zig @@ -1,3 +1,4 @@ +pub const cli = @import("cli.zig"); pub const Benchmark = @import("Benchmark.zig"); pub const TerminalStream = @import("TerminalStream.zig"); diff --git a/src/build/GhosttyBench.zig b/src/build/GhosttyBench.zig index 9e93a3b85..0dc18aa4d 100644 --- a/src/build/GhosttyBench.zig +++ b/src/build/GhosttyBench.zig @@ -14,6 +14,22 @@ pub fn init( var steps = std.ArrayList(*std.Build.Step.Compile).init(b.allocator); errdefer steps.deinit(); + // Our new benchmarking application. + { + const exe = b.addExecutable(.{ + .name = "ghostty-bench", + .root_module = b.createModule(.{ + .root_source_file = b.path("src/main_bench.zig"), + .target = deps.config.target, + // We always want our benchmarks to be in release mode. + .optimize = .ReleaseFast, + }), + }); + exe.linkLibC(); + _ = try deps.add(exe); + try steps.append(exe); + } + // Open the directory ./src/bench const c_dir_path = b.pathFromRoot("src/bench"); var c_dir = try std.fs.cwd().openDir(c_dir_path, .{ .iterate = true }); diff --git a/src/main_bench.zig b/src/main_bench.zig new file mode 100644 index 000000000..9e4af1fc7 --- /dev/null +++ b/src/main_bench.zig @@ -0,0 +1,3 @@ +const std = @import("std"); +const benchmark = @import("benchmark/main.zig"); +pub const main = benchmark.cli.main; From d30771ecffb501512cec5ae43d085560e9d61478 Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Tue, 8 Jul 2025 10:02:06 -0700 Subject: [PATCH 69/94] pkg/macos: use new @ptrcast for os.log --- pkg/macos/os/log.zig | 10 +++++++--- src/benchmark/Benchmark.zig | 2 +- src/main_bench.zig | 2 ++ 3 files changed, 10 insertions(+), 4 deletions(-) diff --git a/pkg/macos/os/log.zig b/pkg/macos/os/log.zig index 8a399b73e..32ecb3296 100644 --- a/pkg/macos/os/log.zig +++ b/pkg/macos/os/log.zig @@ -8,10 +8,10 @@ pub const Log = opaque { subsystem: [:0]const u8, category: [:0]const u8, ) *Log { - return @as(?*Log, @ptrFromInt(@intFromPtr(c.os_log_create( + return @ptrCast(c.os_log_create( subsystem.ptr, category.ptr, - )))).?; + ).?); } pub fn release(self: *Log) void { @@ -32,7 +32,11 @@ pub const Log = opaque { comptime format: []const u8, args: anytype, ) void { - const str = nosuspend std.fmt.allocPrintZ(alloc, format, args) catch return; + const str = nosuspend std.fmt.allocPrintZ( + alloc, + format, + args, + ) catch return; defer alloc.free(str); zig_os_log_with_type(self, typ, str.ptr); } diff --git a/src/benchmark/Benchmark.zig b/src/benchmark/Benchmark.zig index b7d9b6ad3..0bc7539a8 100644 --- a/src/benchmark/Benchmark.zig +++ b/src/benchmark/Benchmark.zig @@ -50,7 +50,7 @@ pub fn run( build_config.bundle_id, macos.os.signpost.Category.points_of_interest, ); - const id = macos.os.signpost.Id.generate(log); + const id = macos.os.signpost.Id.forPointer(log, self.ptr); macos.os.signpost.intervalBegin(log, id, signpost_name); break :macos .{ .log = log, .id = id }; } else {}; diff --git a/src/main_bench.zig b/src/main_bench.zig index 9e4af1fc7..2314dc2ed 100644 --- a/src/main_bench.zig +++ b/src/main_bench.zig @@ -1,3 +1,5 @@ const std = @import("std"); +const builtin = @import("builtin"); const benchmark = @import("benchmark/main.zig"); + pub const main = benchmark.cli.main; From 01b2545d1d97c7c9c9f89ac0f84a067f51a439f0 Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Tue, 8 Jul 2025 16:24:36 -0700 Subject: [PATCH 70/94] macos: fix signpost API to use proper mach header base addr --- pkg/macos/os/signpost.zig | 49 +++++++++++++++++++++++++++++++------ src/benchmark/Benchmark.zig | 3 ++- 2 files changed, 44 insertions(+), 8 deletions(-) diff --git a/pkg/macos/os/signpost.zig b/pkg/macos/os/signpost.zig index 9fef584e4..d7201aa6c 100644 --- a/pkg/macos/os/signpost.zig +++ b/pkg/macos/os/signpost.zig @@ -5,6 +5,38 @@ const c = @import("c.zig").c; const logpkg = @import("log.zig"); const Log = logpkg.Log; +/// This should be called once at the start of the program to intialize +/// some required state for signpost logging. +/// +/// This is all to workaround a Zig bug: +/// https://github.com/ziglang/zig/issues/24370 +pub fn init() void { + if (__dso_handle != null) return; + + // Since __dso_handle is not automatically populated by the linker, + // we populate it by looking up the main function's module address + // which should be a mach-o header. + var info: DlInfo = undefined; + const result = dladdr(@import("root").main, &info); + assert(result != 0); + __dso_handle = @ptrCast(@alignCast(info.dli_fbase)); +} + +/// This should REALLY be an extern var that is populated by the linker, +/// but there is a Zig bug: https://github.com/ziglang/zig/issues/24370 +var __dso_handle: ?*c.mach_header = null; + +// Import the necessary C functions and types +extern "c" fn dladdr(addr: ?*const anyopaque, info: *DlInfo) c_int; + +// Define the Dl_info structure +const DlInfo = extern struct { + dli_fname: [*:0]const u8, // Pathname of shared object + dli_fbase: ?*anyopaque, // Base address of shared object + dli_sname: [*:0]const u8, // Name of nearest symbol + dli_saddr: ?*anyopaque, // Address of nearest symbol +}; + /// Checks whether signpost logging is enabled for the given log handle. /// Returns true if signposts will be recorded for this log, false otherwise. /// This can be used to avoid expensive operations when signpost logging is disabled. @@ -48,8 +80,6 @@ pub fn intervalEnd(log: *Log, id: Id, comptime name: [:0]const u8) void { emitWithName(log, id, .interval_end, name); } -extern var __dso_handle: usize; - /// The internal function to emit a signpost with a specific name. fn emitWithName( log: *Log, @@ -57,14 +87,17 @@ fn emitWithName( typ: Type, comptime name: [:0]const u8, ) void { - var buf: [64]u8 = @splat(0); + // Init must be called by this point. + assert(__dso_handle != null); + + var buf: [2]u8 = @splat(0); c._os_signpost_emit_with_name_impl( - &__dso_handle, + __dso_handle, @ptrCast(log), @intFromEnum(typ), @intFromEnum(id), name.ptr, - null, + "".ptr, &buf, buf.len, ); @@ -135,12 +168,12 @@ pub const Category = struct { /// Dynamic Tracing category enables runtime-configurable logging. /// Signposts in this category can be enabled/disabled dynamically /// without recompiling. - pub const dynamic_tracing: [:0]const u8 = "DynamicTracking"; + pub const dynamic_tracing: [:0]const u8 = "DynamicTracing"; /// Dynamic Stack Tracing category captures call stacks at signpost /// events. This provides deeper debugging information but has higher /// performance overhead. - pub const dynamic_stack_tracing: [:0]const u8 = "DynamicStackTracking"; + pub const dynamic_stack_tracing: [:0]const u8 = "DynamicStackTracing"; }; test { @@ -152,6 +185,8 @@ test enabled { } test "intervals" { + init(); + const log = Log.create("com.mitchellh.ghostty", "test"); defer log.release(); diff --git a/src/benchmark/Benchmark.zig b/src/benchmark/Benchmark.zig index 0bc7539a8..523ea6f9a 100644 --- a/src/benchmark/Benchmark.zig +++ b/src/benchmark/Benchmark.zig @@ -41,11 +41,12 @@ pub fn run( // If we're on macOS, we setup signposts so its easier to find // the results in Instruments. There's a lot of nasty comptime stuff // here but its just to ensure this does nothing on other platforms. - const signpost_name = "Ghostty Benchmark"; + const signpost_name = "ghostty"; const signpost: if (builtin.target.os.tag.isDarwin()) struct { log: *macos.os.Log, id: macos.os.signpost.Id, } else void = if (comptime builtin.os.tag == .macos) macos: { + macos.os.signpost.init(); const log = macos.os.Log.create( build_config.bundle_id, macos.os.signpost.Category.points_of_interest, From 20bb71c627f2e0e5cb3b8b318f877cbb33d088f1 Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Wed, 9 Jul 2025 11:33:00 -0700 Subject: [PATCH 71/94] libghostty: export benchmark CLI API --- include/ghostty.h | 3 ++ pkg/macos/os/signpost.zig | 15 +++++++- src/benchmark/Benchmark.zig | 6 ++-- src/benchmark/CApi.zig | 34 ++++++++++++++++++ src/benchmark/cli.zig | 60 ++++++++++++++++++++++--------- src/benchmark/main.zig | 1 + src/config.zig | 2 +- src/config/{CAPI.zig => CApi.zig} | 0 src/main_c.zig | 24 ++++++++----- 9 files changed, 114 insertions(+), 31 deletions(-) create mode 100644 src/benchmark/CApi.zig rename src/config/{CAPI.zig => CApi.zig} (100%) diff --git a/include/ghostty.h b/include/ghostty.h index 312e6595a..bcd88251b 100644 --- a/include/ghostty.h +++ b/include/ghostty.h @@ -932,6 +932,9 @@ bool ghostty_inspector_metal_shutdown(ghostty_inspector_t); // Don't use these unless you know what you're doing. void ghostty_set_window_background_blur(ghostty_app_t, void*); +// Benchmark API, if available. +bool ghostty_benchmark_cli(const char*, const char*); + #ifdef __cplusplus } #endif diff --git a/pkg/macos/os/signpost.zig b/pkg/macos/os/signpost.zig index d7201aa6c..0be6ad4b1 100644 --- a/pkg/macos/os/signpost.zig +++ b/pkg/macos/os/signpost.zig @@ -13,11 +13,24 @@ const Log = logpkg.Log; pub fn init() void { if (__dso_handle != null) return; + const root = @import("root"); + const sym = if (@hasDecl(root, "main")) + root.main + else + comptime first: { + for (@typeInfo(root).@"struct".decls) |decl_info| { + const decl = @field(root, decl_info.name); + if (@typeInfo(@TypeOf(decl)) == .@"fn") break :first decl; + } + + @compileError("No functions found in root module"); + }; + // Since __dso_handle is not automatically populated by the linker, // we populate it by looking up the main function's module address // which should be a mach-o header. var info: DlInfo = undefined; - const result = dladdr(@import("root").main, &info); + const result = dladdr(sym, &info); assert(result != 0); __dso_handle = @ptrCast(@alignCast(info.dli_fbase)); } diff --git a/src/benchmark/Benchmark.zig b/src/benchmark/Benchmark.zig index 523ea6f9a..4128a7adc 100644 --- a/src/benchmark/Benchmark.zig +++ b/src/benchmark/Benchmark.zig @@ -45,7 +45,7 @@ pub fn run( const signpost: if (builtin.target.os.tag.isDarwin()) struct { log: *macos.os.Log, id: macos.os.signpost.Id, - } else void = if (comptime builtin.os.tag == .macos) macos: { + } else void = if (builtin.target.os.tag.isDarwin()) darwin: { macos.os.signpost.init(); const log = macos.os.Log.create( build_config.bundle_id, @@ -53,9 +53,9 @@ pub fn run( ); const id = macos.os.signpost.Id.forPointer(log, self.ptr); macos.os.signpost.intervalBegin(log, id, signpost_name); - break :macos .{ .log = log, .id = id }; + break :darwin .{ .log = log, .id = id }; } else {}; - defer if (comptime builtin.os.tag == .macos) { + defer if (comptime builtin.target.os.tag.isDarwin()) { macos.os.signpost.intervalEnd( signpost.log, signpost.id, diff --git a/src/benchmark/CApi.zig b/src/benchmark/CApi.zig new file mode 100644 index 000000000..3bef8b269 --- /dev/null +++ b/src/benchmark/CApi.zig @@ -0,0 +1,34 @@ +const std = @import("std"); +const cli = @import("cli.zig"); +const state = &@import("../global.zig").state; + +const log = std.log.scoped(.benchmark); + +/// Run the Ghostty benchmark CLI with the given action and arguments. +export fn ghostty_benchmark_cli( + action_name_: [*:0]const u8, + args: [*:0]const u8, +) bool { + const action_name = std.mem.sliceTo(action_name_, 0); + const action: cli.Action = std.meta.stringToEnum( + cli.Action, + action_name, + ) orelse { + log.warn("unknown action={s}", .{action_name}); + return false; + }; + + cli.mainAction( + state.alloc, + action, + .{ .string = std.mem.sliceTo(args, 0) }, + ) catch |err| { + log.warn("failed to run action={s} err={}", .{ + @tagName(action), + err, + }); + return false; + }; + + return true; +} diff --git a/src/benchmark/cli.zig b/src/benchmark/cli.zig index 781eafd24..c0b8dcea6 100644 --- a/src/benchmark/cli.zig +++ b/src/benchmark/cli.zig @@ -4,7 +4,7 @@ const cli = @import("../cli.zig"); /// The available actions for the CLI. This is the list of available /// benchmarks. -const Action = enum { +pub const Action = enum { @"terminal-stream", /// Returns the struct associated with the action. The struct @@ -24,31 +24,57 @@ const Action = enum { /// An entrypoint for the benchmark CLI. pub fn main() !void { - // TODO: Better terminal output throughout this, use libvaxis. - const alloc = std.heap.c_allocator; const action_ = try cli.action.detectArgs(Action, alloc); const action = action_ orelse return error.NoAction; - - // We need a comptime action to get the struct type and do the - // rest. - return switch (action) { - inline else => |comptime_action| { - const BenchmarkImpl = Action.Struct(comptime_action); - try mainAction(BenchmarkImpl, alloc); - }, - }; + try mainAction(alloc, action, .cli); } -fn mainAction(comptime BenchmarkImpl: type, alloc: Allocator) !void { +/// Arguments that can be passed to the benchmark. +pub const Args = union(enum) { + /// The arguments passed to the CLI via argc/argv. + cli, + + /// Simple string arguments, parsed via std.process.ArgIteratorGeneral. + string: []const u8, +}; + +pub fn mainAction( + alloc: Allocator, + action: Action, + args: Args, +) !void { + switch (action) { + inline else => |comptime_action| { + const BenchmarkImpl = Action.Struct(comptime_action); + try mainActionImpl(BenchmarkImpl, alloc, args); + }, + } +} + +fn mainActionImpl( + comptime BenchmarkImpl: type, + alloc: Allocator, + args: Args, +) !void { // First, parse our CLI options. const Options = BenchmarkImpl.Options; var opts: Options = .{}; defer if (@hasDecl(Options, "deinit")) opts.deinit(); - { - var iter = try cli.args.argsIterator(alloc); - defer iter.deinit(); - try cli.args.parse(Options, alloc, &opts, &iter); + switch (args) { + .cli => { + var iter = try cli.args.argsIterator(alloc); + defer iter.deinit(); + try cli.args.parse(Options, alloc, &opts, &iter); + }, + .string => |str| { + var iter = try std.process.ArgIteratorGeneral(.{}).init( + alloc, + str, + ); + defer iter.deinit(); + try cli.args.parse(Options, alloc, &opts, &iter); + }, } // Create our implementation diff --git a/src/benchmark/main.zig b/src/benchmark/main.zig index 93e489578..010f11805 100644 --- a/src/benchmark/main.zig +++ b/src/benchmark/main.zig @@ -1,5 +1,6 @@ pub const cli = @import("cli.zig"); pub const Benchmark = @import("Benchmark.zig"); +pub const CApi = @import("CApi.zig"); pub const TerminalStream = @import("TerminalStream.zig"); test { diff --git a/src/config.zig b/src/config.zig index efc9fd973..c5bab5877 100644 --- a/src/config.zig +++ b/src/config.zig @@ -41,7 +41,7 @@ pub const BackgroundImageFit = Config.BackgroundImageFit; pub const LinkPreviews = Config.LinkPreviews; // Alternate APIs -pub const CAPI = @import("config/CAPI.zig"); +pub const CApi = @import("config/CApi.zig"); pub const Wasm = if (!builtin.target.cpu.arch.isWasm()) struct {} else @import("config/Wasm.zig"); test { diff --git a/src/config/CAPI.zig b/src/config/CApi.zig similarity index 100% rename from src/config/CAPI.zig rename to src/config/CApi.zig diff --git a/src/main_c.zig b/src/main_c.zig index 2c266cfb5..9a9bcc6d2 100644 --- a/src/main_c.zig +++ b/src/main_c.zig @@ -33,10 +33,16 @@ pub const std_options = main.std_options; comptime { // These structs need to be referenced so the `export` functions // are truly exported by the C API lib. - _ = @import("config.zig").CAPI; - if (@hasDecl(apprt.runtime, "CAPI")) { - _ = apprt.runtime.CAPI; - } + + // Our config API + _ = @import("config.zig").CApi; + + // Any apprt-specific C API, mainly libghostty for apprt.embedded. + if (@hasDecl(apprt.runtime, "CAPI")) _ = apprt.runtime.CAPI; + + // Our benchmark API. We probably want to gate this on a build + // config in the future but for now we always just export it. + _ = @import("benchmark/main.zig").CApi; } /// ghostty_info_s @@ -72,7 +78,7 @@ pub const String = extern struct { }; /// Initialize ghostty global state. -export fn ghostty_init(argc: usize, argv: [*][*:0]u8) c_int { +pub export fn ghostty_init(argc: usize, argv: [*][*:0]u8) c_int { assert(builtin.link_libc); std.os.argv = argv[0..argc]; @@ -86,7 +92,7 @@ export fn ghostty_init(argc: usize, argv: [*][*:0]u8) c_int { /// Runs an action if it is specified. If there is no action this returns /// false. If there is an action then this doesn't return. -export fn ghostty_cli_try_action() void { +pub export fn ghostty_cli_try_action() void { const action = state.action orelse return; std.log.info("executing CLI action={}", .{action}); posix.exit(action.run(state.alloc) catch |err| { @@ -98,7 +104,7 @@ export fn ghostty_cli_try_action() void { } /// Return metadata about Ghostty, such as version, build mode, etc. -export fn ghostty_info() Info { +pub export fn ghostty_info() Info { return .{ .mode = switch (builtin.mode) { .Debug => .debug, @@ -117,11 +123,11 @@ export fn ghostty_info() Info { /// the function call. /// /// This should only be used for singular strings maintained by Ghostty. -export fn ghostty_translate(msgid: [*:0]const u8) [*:0]const u8 { +pub export fn ghostty_translate(msgid: [*:0]const u8) [*:0]const u8 { return internal_os.i18n._(msgid); } /// Free a string allocated by Ghostty. -export fn ghostty_string_free(str: String) void { +pub export fn ghostty_string_free(str: String) void { state.alloc.free(str.ptr.?[0..str.len]); } From c990d35d6dc7e23e1c72d172cde00e62c3c99389 Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Wed, 9 Jul 2025 13:22:04 -0700 Subject: [PATCH 72/94] macos: add benchmark tests to our Xcode project --- macos/Ghostty.xcodeproj/project.pbxproj | 158 +++++++++++++++++- .../xcshareddata/xcschemes/Ghostty.xcscheme | 13 ++ macos/GhosttyTests/BenchmarkTests.swift | 32 ++++ pkg/macos/os/signpost.zig | 27 +-- src/benchmark/TerminalStream.zig | 15 +- 5 files changed, 229 insertions(+), 16 deletions(-) create mode 100644 macos/GhosttyTests/BenchmarkTests.swift diff --git a/macos/Ghostty.xcodeproj/project.pbxproj b/macos/Ghostty.xcodeproj/project.pbxproj index f6eedd864..f7ae5f525 100644 --- a/macos/Ghostty.xcodeproj/project.pbxproj +++ b/macos/Ghostty.xcodeproj/project.pbxproj @@ -3,7 +3,7 @@ archiveVersion = 1; classes = { }; - objectVersion = 56; + objectVersion = 70; objects = { /* Begin PBXBuildFile section */ @@ -152,6 +152,16 @@ FC9ABA9C2D0F53F80020D4C8 /* bash-completion in Resources */ = {isa = PBXBuildFile; fileRef = FC9ABA9B2D0F538D0020D4C8 /* bash-completion */; }; /* End PBXBuildFile section */ +/* Begin PBXContainerItemProxy section */ + A54F45F72E1F047A0046BD5C /* PBXContainerItemProxy */ = { + isa = PBXContainerItemProxy; + containerPortal = A5B30529299BEAAA0047F10C /* Project object */; + proxyType = 1; + remoteGlobalIDString = A5B30530299BEAAA0047F10C; + remoteInfo = Ghostty; + }; +/* End PBXContainerItemProxy section */ + /* Begin PBXFileReference section */ 29C15B1C2CDC3B2000520DD4 /* bat */ = {isa = PBXFileReference; lastKnownFileType = folder; name = bat; path = "../zig-out/share/bat"; sourceTree = ""; }; 3B39CAA42B33949B00DABEB8 /* GhosttyReleaseLocal.entitlements */ = {isa = PBXFileReference; lastKnownFileType = text.plist.entitlements; path = GhosttyReleaseLocal.entitlements; sourceTree = ""; }; @@ -199,6 +209,7 @@ A54B0CEC2D0CFB7300CBEFF8 /* ColorizedGhosttyIcon.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ColorizedGhosttyIcon.swift; sourceTree = ""; }; A54B0CEE2D0D2E2400CBEFF8 /* ColorizedGhosttyIconImage.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ColorizedGhosttyIconImage.swift; sourceTree = ""; }; A54D786B2CA79788001B19B1 /* BaseTerminalController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BaseTerminalController.swift; sourceTree = ""; }; + A54F45F32E1F047A0046BD5C /* GhosttyTests.xctest */ = {isa = PBXFileReference; explicitFileType = wrapper.cfbundle; includeInIndex = 0; path = GhosttyTests.xctest; sourceTree = BUILT_PRODUCTS_DIR; }; A553F4122E06EB1600257779 /* Ghostty.icon */ = {isa = PBXFileReference; lastKnownFileType = folder.iconcomposer.icon; name = Ghostty.icon; path = ../images/Ghostty.icon; sourceTree = SOURCE_ROOT; }; A5593FDE2DF8D57100B47B10 /* TerminalWindow.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TerminalWindow.swift; sourceTree = ""; }; A5593FE02DF8D73400B47B10 /* HiddenTitlebarTerminalWindow.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = HiddenTitlebarTerminalWindow.swift; sourceTree = ""; }; @@ -291,7 +302,18 @@ FC9ABA9B2D0F538D0020D4C8 /* bash-completion */ = {isa = PBXFileReference; lastKnownFileType = folder; name = "bash-completion"; path = "../zig-out/share/bash-completion"; sourceTree = ""; }; /* End PBXFileReference section */ +/* Begin PBXFileSystemSynchronizedRootGroup section */ + A54F45F42E1F047A0046BD5C /* GhosttyTests */ = {isa = PBXFileSystemSynchronizedRootGroup; explicitFileTypes = {}; explicitFolders = (); path = GhosttyTests; sourceTree = ""; }; +/* End PBXFileSystemSynchronizedRootGroup section */ + /* Begin PBXFrameworksBuildPhase section */ + A54F45F02E1F047A0046BD5C /* Frameworks */ = { + isa = PBXFrameworksBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + runOnlyForDeploymentPostprocessing = 0; + }; A5B3052E299BEAAA0047F10C /* Frameworks */ = { isa = PBXFrameworksBuildPhase; buildActionMask = 2147483647; @@ -590,6 +612,7 @@ A51BFC282B30F26D00E92F16 /* GhosttyDebug.entitlements */, 3B39CAA42B33949B00DABEB8 /* GhosttyReleaseLocal.entitlements */, A54CD6ED299BEB14008C95BB /* Sources */, + A54F45F42E1F047A0046BD5C /* GhosttyTests */, A5D495A3299BECBA00DD1313 /* Frameworks */, A5A1F8862A489D7400D1E8BC /* Resources */, A5B30532299BEAAA0047F10C /* Products */, @@ -601,6 +624,7 @@ children = ( A5B30531299BEAAA0047F10C /* Ghostty.app */, A5D4499D2B53AE7B000F5B83 /* Ghostty-iOS.app */, + A54F45F32E1F047A0046BD5C /* GhosttyTests.xctest */, ); name = Products; sourceTree = ""; @@ -674,6 +698,29 @@ /* End PBXGroup section */ /* Begin PBXNativeTarget section */ + A54F45F22E1F047A0046BD5C /* GhosttyTests */ = { + isa = PBXNativeTarget; + buildConfigurationList = A54F45FC2E1F047A0046BD5C /* Build configuration list for PBXNativeTarget "GhosttyTests" */; + buildPhases = ( + A54F45EF2E1F047A0046BD5C /* Sources */, + A54F45F02E1F047A0046BD5C /* Frameworks */, + A54F45F12E1F047A0046BD5C /* Resources */, + ); + buildRules = ( + ); + dependencies = ( + A54F45F82E1F047A0046BD5C /* PBXTargetDependency */, + ); + fileSystemSynchronizedGroups = ( + A54F45F42E1F047A0046BD5C /* GhosttyTests */, + ); + name = GhosttyTests; + packageProductDependencies = ( + ); + productName = GhosttyTests; + productReference = A54F45F32E1F047A0046BD5C /* GhosttyTests.xctest */; + productType = "com.apple.product-type.bundle.unit-test"; + }; A5B30530299BEAAA0047F10C /* Ghostty */ = { isa = PBXNativeTarget; buildConfigurationList = A5B30540299BEAAB0047F10C /* Build configuration list for PBXNativeTarget "Ghostty" */; @@ -718,9 +765,13 @@ isa = PBXProject; attributes = { BuildIndependentTargetsInParallel = 1; - LastSwiftUpdateCheck = 1520; + LastSwiftUpdateCheck = 2600; LastUpgradeCheck = 1610; TargetAttributes = { + A54F45F22E1F047A0046BD5C = { + CreatedOnToolsVersion = 26.0; + TestTargetID = A5B30530299BEAAA0047F10C; + }; A5B30530299BEAAA0047F10C = { CreatedOnToolsVersion = 14.2; LastSwiftMigration = 1510; @@ -748,11 +799,19 @@ targets = ( A5B30530299BEAAA0047F10C /* Ghostty */, A5D4499C2B53AE7B000F5B83 /* Ghostty-iOS */, + A54F45F22E1F047A0046BD5C /* GhosttyTests */, ); }; /* End PBXProject section */ /* Begin PBXResourcesBuildPhase section */ + A54F45F12E1F047A0046BD5C /* Resources */ = { + isa = PBXResourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + runOnlyForDeploymentPostprocessing = 0; + }; A5B3052F299BEAAA0047F10C /* Resources */ = { isa = PBXResourcesBuildPhase; buildActionMask = 2147483647; @@ -794,6 +853,13 @@ /* End PBXResourcesBuildPhase section */ /* Begin PBXSourcesBuildPhase section */ + A54F45EF2E1F047A0046BD5C /* Sources */ = { + isa = PBXSourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + runOnlyForDeploymentPostprocessing = 0; + }; A5B3052D299BEAAA0047F10C /* Sources */ = { isa = PBXSourcesBuildPhase; buildActionMask = 2147483647; @@ -925,6 +991,14 @@ }; /* End PBXSourcesBuildPhase section */ +/* Begin PBXTargetDependency section */ + A54F45F82E1F047A0046BD5C /* PBXTargetDependency */ = { + isa = PBXTargetDependency; + target = A5B30530299BEAAA0047F10C /* Ghostty */; + targetProxy = A54F45F72E1F047A0046BD5C /* PBXContainerItemProxy */; + }; +/* End PBXTargetDependency section */ + /* Begin XCBuildConfiguration section */ 3B39CAA22B33946300DABEB8 /* ReleaseLocal */ = { isa = XCBuildConfiguration; @@ -1034,6 +1108,76 @@ }; name = ReleaseLocal; }; + A54F45F92E1F047A0046BD5C /* Debug */ = { + isa = XCBuildConfiguration; + buildSettings = { + BUNDLE_LOADER = "$(TEST_HOST)"; + CODE_SIGN_STYLE = Automatic; + CURRENT_PROJECT_VERSION = 1; + ENABLE_USER_SCRIPT_SANDBOXING = YES; + GCC_C_LANGUAGE_STANDARD = gnu17; + GENERATE_INFOPLIST_FILE = YES; + LOCALIZATION_PREFERS_STRING_CATALOGS = YES; + MACOSX_DEPLOYMENT_TARGET = 15.5; + MARKETING_VERSION = 1.0; + PRODUCT_BUNDLE_IDENTIFIER = com.mitchellh.GhosttyTests; + PRODUCT_NAME = "$(TARGET_NAME)"; + STRING_CATALOG_GENERATE_SYMBOLS = NO; + SWIFT_ACTIVE_COMPILATION_CONDITIONS = "DEBUG $(inherited)"; + SWIFT_APPROACHABLE_CONCURRENCY = YES; + SWIFT_EMIT_LOC_STRINGS = NO; + SWIFT_UPCOMING_FEATURE_MEMBER_IMPORT_VISIBILITY = YES; + SWIFT_VERSION = 5.0; + TEST_HOST = "$(BUILT_PRODUCTS_DIR)/Ghostty.app/$(BUNDLE_EXECUTABLE_FOLDER_PATH)/ghostty"; + }; + name = Debug; + }; + A54F45FA2E1F047A0046BD5C /* Release */ = { + isa = XCBuildConfiguration; + buildSettings = { + BUNDLE_LOADER = "$(TEST_HOST)"; + CODE_SIGN_STYLE = Automatic; + CURRENT_PROJECT_VERSION = 1; + ENABLE_USER_SCRIPT_SANDBOXING = YES; + GCC_C_LANGUAGE_STANDARD = gnu17; + GENERATE_INFOPLIST_FILE = YES; + LOCALIZATION_PREFERS_STRING_CATALOGS = YES; + MACOSX_DEPLOYMENT_TARGET = 15.5; + MARKETING_VERSION = 1.0; + PRODUCT_BUNDLE_IDENTIFIER = com.mitchellh.GhosttyTests; + PRODUCT_NAME = "$(TARGET_NAME)"; + STRING_CATALOG_GENERATE_SYMBOLS = NO; + SWIFT_APPROACHABLE_CONCURRENCY = YES; + SWIFT_EMIT_LOC_STRINGS = NO; + SWIFT_UPCOMING_FEATURE_MEMBER_IMPORT_VISIBILITY = YES; + SWIFT_VERSION = 5.0; + TEST_HOST = "$(BUILT_PRODUCTS_DIR)/Ghostty.app/$(BUNDLE_EXECUTABLE_FOLDER_PATH)/ghostty"; + }; + name = Release; + }; + A54F45FB2E1F047A0046BD5C /* ReleaseLocal */ = { + isa = XCBuildConfiguration; + buildSettings = { + BUNDLE_LOADER = "$(TEST_HOST)"; + CODE_SIGN_STYLE = Automatic; + CURRENT_PROJECT_VERSION = 1; + ENABLE_USER_SCRIPT_SANDBOXING = YES; + GCC_C_LANGUAGE_STANDARD = gnu17; + GENERATE_INFOPLIST_FILE = YES; + LOCALIZATION_PREFERS_STRING_CATALOGS = YES; + MACOSX_DEPLOYMENT_TARGET = 15.5; + MARKETING_VERSION = 1.0; + PRODUCT_BUNDLE_IDENTIFIER = com.mitchellh.GhosttyTests; + PRODUCT_NAME = "$(TARGET_NAME)"; + STRING_CATALOG_GENERATE_SYMBOLS = NO; + SWIFT_APPROACHABLE_CONCURRENCY = YES; + SWIFT_EMIT_LOC_STRINGS = NO; + SWIFT_UPCOMING_FEATURE_MEMBER_IMPORT_VISIBILITY = YES; + SWIFT_VERSION = 5.0; + TEST_HOST = "$(BUILT_PRODUCTS_DIR)/Ghostty.app/$(BUNDLE_EXECUTABLE_FOLDER_PATH)/ghostty"; + }; + name = ReleaseLocal; + }; A5B3053E299BEAAB0047F10C /* Debug */ = { isa = XCBuildConfiguration; buildSettings = { @@ -1378,6 +1522,16 @@ /* End XCBuildConfiguration section */ /* Begin XCConfigurationList section */ + A54F45FC2E1F047A0046BD5C /* Build configuration list for PBXNativeTarget "GhosttyTests" */ = { + isa = XCConfigurationList; + buildConfigurations = ( + A54F45F92E1F047A0046BD5C /* Debug */, + A54F45FA2E1F047A0046BD5C /* Release */, + A54F45FB2E1F047A0046BD5C /* ReleaseLocal */, + ); + defaultConfigurationIsVisible = 0; + defaultConfigurationName = ReleaseLocal; + }; A5B3052C299BEAAA0047F10C /* Build configuration list for PBXProject "Ghostty" */ = { isa = XCConfigurationList; buildConfigurations = ( diff --git a/macos/Ghostty.xcodeproj/xcshareddata/xcschemes/Ghostty.xcscheme b/macos/Ghostty.xcodeproj/xcshareddata/xcschemes/Ghostty.xcscheme index 5900042f2..0d8761c9e 100644 --- a/macos/Ghostty.xcodeproj/xcshareddata/xcschemes/Ghostty.xcscheme +++ b/macos/Ghostty.xcodeproj/xcshareddata/xcschemes/Ghostty.xcscheme @@ -28,6 +28,19 @@ selectedLauncherIdentifier = "Xcode.DebuggerFoundation.Launcher.LLDB" shouldUseLaunchSchemeArgsEnv = "YES" shouldAutocreateTestPlan = "YES"> + + + + + + Date: Wed, 9 Jul 2025 14:23:59 -0700 Subject: [PATCH 73/94] benchmark: add codepoint width benchmark --- src/benchmark/CodepointWidth.zig | 204 +++++++++++++++++++++++++++++++ src/benchmark/TerminalStream.zig | 11 +- src/benchmark/cli.zig | 2 + src/benchmark/main.zig | 1 + src/benchmark/options.zig | 20 +++ 5 files changed, 232 insertions(+), 6 deletions(-) create mode 100644 src/benchmark/CodepointWidth.zig create mode 100644 src/benchmark/options.zig diff --git a/src/benchmark/CodepointWidth.zig b/src/benchmark/CodepointWidth.zig new file mode 100644 index 000000000..e9207aed5 --- /dev/null +++ b/src/benchmark/CodepointWidth.zig @@ -0,0 +1,204 @@ +//! This benchmark tests the throughput of codepoint width calculation. +//! This is a common operation in terminal character printing and the +//! motivating factor to write this benchmark was discovering that our +//! codepoint width function was 30% of the runtime of every character +//! print. +const CodepointWidth = @This(); + +const std = @import("std"); +const assert = std.debug.assert; +const Allocator = std.mem.Allocator; +const Benchmark = @import("Benchmark.zig"); +const options = @import("options.zig"); +const UTF8Decoder = @import("../terminal/UTF8Decoder.zig"); +const simd = @import("../simd/main.zig"); +const table = @import("../unicode/main.zig").table; + +const log = std.log.scoped(.@"terminal-stream-bench"); + +opts: Options, + +/// The file, opened in the setup function. +data_f: ?std.fs.File = null, + +pub const Options = struct { + /// The type of codepoint width calculation to use. + mode: Mode = .noop, + + /// The data to read as a filepath. If this is "-" then + /// we will read stdin. If this is unset, then we will + /// do nothing (benchmark is a noop). It'd be more unixy to + /// use stdin by default but I find that a hanging CLI command + /// with no interaction is a bit annoying. + data: ?[]const u8 = null, +}; + +pub const Mode = enum { + /// The baseline mode copies the data from the fd into a buffer. This + /// is used to show the minimal overhead of reading the fd into memory + /// and establishes a baseline for the other modes. + noop, + + /// libc wcwidth + wcwidth, + + /// Our SIMD implementation. + simd, + + /// Test our lookup table implementation. + table, +}; + +/// Create a new terminal stream handler for the given arguments. +pub fn create( + alloc: Allocator, + opts: Options, +) !*CodepointWidth { + const ptr = try alloc.create(CodepointWidth); + errdefer alloc.destroy(ptr); + ptr.* = .{ .opts = opts }; + return ptr; +} + +pub fn destroy(self: *CodepointWidth, alloc: Allocator) void { + alloc.destroy(self); +} + +pub fn benchmark(self: *CodepointWidth) Benchmark { + return .init(self, .{ + .stepFn = switch (self.opts.mode) { + .noop => stepNoop, + .wcwidth => stepWcwidth, + .table => stepTable, + .simd => stepSimd, + }, + .setupFn = setup, + .teardownFn = teardown, + }); +} + +fn setup(ptr: *anyopaque) Benchmark.Error!void { + const self: *CodepointWidth = @ptrCast(@alignCast(ptr)); + + // Open our data file to prepare for reading. We can do more + // validation here eventually. + assert(self.data_f == null); + self.data_f = options.dataFile(self.opts.data) catch |err| { + log.warn("error opening data file err={}", .{err}); + return error.BenchmarkFailed; + }; +} + +fn teardown(ptr: *anyopaque) void { + const self: *CodepointWidth = @ptrCast(@alignCast(ptr)); + if (self.data_f) |f| { + f.close(); + self.data_f = null; + } +} + +fn stepNoop(ptr: *anyopaque) Benchmark.Error!void { + _ = ptr; +} + +extern "c" fn wcwidth(c: u32) c_int; + +fn stepWcwidth(ptr: *anyopaque) Benchmark.Error!void { + const self: *CodepointWidth = @ptrCast(@alignCast(ptr)); + + const f = self.data_f orelse return; + var r = std.io.bufferedReader(f.reader()); + var d: UTF8Decoder = .{}; + var buf: [4096]u8 = undefined; + while (true) { + const n = r.read(&buf) catch |err| { + log.warn("error reading data file err={}", .{err}); + return error.BenchmarkFailed; + }; + if (n == 0) break; // EOF reached + + for (buf[0..n]) |c| { + const cp_, const consumed = d.next(c); + assert(consumed); + if (cp_) |cp| { + const width = wcwidth(cp); + + // Write the width to the buffer to avoid it being compiled + // away + buf[0] = @intCast(width); + } + } + } +} + +fn stepTable(ptr: *anyopaque) Benchmark.Error!void { + const self: *CodepointWidth = @ptrCast(@alignCast(ptr)); + + const f = self.data_f orelse return; + var r = std.io.bufferedReader(f.reader()); + var d: UTF8Decoder = .{}; + var buf: [4096]u8 = undefined; + while (true) { + const n = r.read(&buf) catch |err| { + log.warn("error reading data file err={}", .{err}); + return error.BenchmarkFailed; + }; + if (n == 0) break; // EOF reached + + for (buf[0..n]) |c| { + const cp_, const consumed = d.next(c); + assert(consumed); + if (cp_) |cp| { + // This is the same trick we do in terminal.zig so we + // keep it here. + const width = if (cp <= 0xFF) + 1 + else + table.get(@intCast(cp)).width; + + // Write the width to the buffer to avoid it being compiled + // away + buf[0] = @intCast(width); + } + } + } +} + +fn stepSimd(ptr: *anyopaque) Benchmark.Error!void { + const self: *CodepointWidth = @ptrCast(@alignCast(ptr)); + + const f = self.data_f orelse return; + var r = std.io.bufferedReader(f.reader()); + var d: UTF8Decoder = .{}; + var buf: [4096]u8 = undefined; + while (true) { + const n = r.read(&buf) catch |err| { + log.warn("error reading data file err={}", .{err}); + return error.BenchmarkFailed; + }; + if (n == 0) break; // EOF reached + + for (buf[0..n]) |c| { + const cp_, const consumed = d.next(c); + assert(consumed); + if (cp_) |cp| { + const width = simd.codepointWidth(cp); + + // Write the width to the buffer to avoid it being compiled + // away + buf[0] = @intCast(width); + } + } + } +} + +test CodepointWidth { + const testing = std.testing; + const alloc = testing.allocator; + + const impl: *CodepointWidth = try .create(alloc, .{}); + defer impl.destroy(alloc); + + const bench = impl.benchmark(); + _ = try bench.run(.once); +} diff --git a/src/benchmark/TerminalStream.zig b/src/benchmark/TerminalStream.zig index 3b47fe879..5d235c4ee 100644 --- a/src/benchmark/TerminalStream.zig +++ b/src/benchmark/TerminalStream.zig @@ -18,6 +18,7 @@ const assert = std.debug.assert; const Allocator = std.mem.Allocator; const terminalpkg = @import("../terminal/main.zig"); const Benchmark = @import("Benchmark.zig"); +const options = @import("options.zig"); const Terminal = terminalpkg.Terminal; const Stream = terminalpkg.Stream(*Handler); @@ -89,12 +90,10 @@ fn setup(ptr: *anyopaque) Benchmark.Error!void { // Open our data file to prepare for reading. We can do more // validation here eventually. assert(self.data_f == null); - if (self.opts.data) |path| { - self.data_f = std.fs.cwd().openFile(path, .{}) catch |err| { - log.warn("error opening data file err={}", .{err}); - return error.BenchmarkFailed; - }; - } + self.data_f = options.dataFile(self.opts.data) catch |err| { + log.warn("error opening data file err={}", .{err}); + return error.BenchmarkFailed; + }; } fn teardown(ptr: *anyopaque) void { diff --git a/src/benchmark/cli.zig b/src/benchmark/cli.zig index c0b8dcea6..b35159c6b 100644 --- a/src/benchmark/cli.zig +++ b/src/benchmark/cli.zig @@ -6,6 +6,7 @@ const cli = @import("../cli.zig"); /// benchmarks. pub const Action = enum { @"terminal-stream", + @"codepoint-width", /// Returns the struct associated with the action. The struct /// should have a few decls: @@ -18,6 +19,7 @@ pub const Action = enum { pub fn Struct(comptime action: Action) type { return switch (action) { .@"terminal-stream" => @import("TerminalStream.zig"), + .@"codepoint-width" => @import("CodepointWidth.zig"), }; } }; diff --git a/src/benchmark/main.zig b/src/benchmark/main.zig index 010f11805..dd00f72b5 100644 --- a/src/benchmark/main.zig +++ b/src/benchmark/main.zig @@ -2,6 +2,7 @@ pub const cli = @import("cli.zig"); pub const Benchmark = @import("Benchmark.zig"); pub const CApi = @import("CApi.zig"); pub const TerminalStream = @import("TerminalStream.zig"); +pub const CodepointWidth = @import("CodepointWidth.zig"); test { _ = @import("std").testing.refAllDecls(@This()); diff --git a/src/benchmark/options.zig b/src/benchmark/options.zig new file mode 100644 index 000000000..867be6afc --- /dev/null +++ b/src/benchmark/options.zig @@ -0,0 +1,20 @@ +//! This file contains helpers for CLI options. + +const std = @import("std"); + +/// Returns the data file for the given path in a way that is consistent +/// across our CLI. If the path is not set then no file is returned. +/// If the path is "-", then we will return stdin. If the path is +/// a file then we will open and return the handle. +pub fn dataFile(path_: ?[]const u8) !?std.fs.File { + const path = path_ orelse return null; + + // Stdin + if (std.mem.eql(u8, path, "-")) return std.io.getStdIn(); + + // Normal file + const file = try std.fs.cwd().openFile(path, .{}); + errdefer file.close(); + + return file; +} From 99ed984af2a12340a7b5b17326bc037044524ba4 Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Wed, 9 Jul 2025 14:31:47 -0700 Subject: [PATCH 74/94] benchmark: add GraphemeBreak and TerminalParser benchmarks --- src/benchmark/GraphemeBreak.zig | 146 +++++++++++++++++++++++++++++++ src/benchmark/TerminalParser.zig | 106 ++++++++++++++++++++++ src/benchmark/cli.zig | 4 + src/benchmark/main.zig | 2 + 4 files changed, 258 insertions(+) create mode 100644 src/benchmark/GraphemeBreak.zig create mode 100644 src/benchmark/TerminalParser.zig diff --git a/src/benchmark/GraphemeBreak.zig b/src/benchmark/GraphemeBreak.zig new file mode 100644 index 000000000..57effebe4 --- /dev/null +++ b/src/benchmark/GraphemeBreak.zig @@ -0,0 +1,146 @@ +//! This benchmark tests the throughput of grapheme break calculation. +//! This is a common operation in terminal character printing for terminals +//! that support grapheme clustering. +const GraphemeBreak = @This(); + +const std = @import("std"); +const assert = std.debug.assert; +const Allocator = std.mem.Allocator; +const Benchmark = @import("Benchmark.zig"); +const options = @import("options.zig"); +const UTF8Decoder = @import("../terminal/UTF8Decoder.zig"); +const unicode = @import("../unicode/main.zig"); + +const log = std.log.scoped(.@"terminal-stream-bench"); + +opts: Options, + +/// The file, opened in the setup function. +data_f: ?std.fs.File = null, + +pub const Options = struct { + /// The type of codepoint width calculation to use. + mode: Mode = .table, + + /// The data to read as a filepath. If this is "-" then + /// we will read stdin. If this is unset, then we will + /// do nothing (benchmark is a noop). It'd be more unixy to + /// use stdin by default but I find that a hanging CLI command + /// with no interaction is a bit annoying. + data: ?[]const u8 = null, +}; + +pub const Mode = enum { + /// The baseline mode copies the data from the fd into a buffer. This + /// is used to show the minimal overhead of reading the fd into memory + /// and establishes a baseline for the other modes. + noop, + + /// Ghostty's table-based approach. + table, +}; + +/// Create a new terminal stream handler for the given arguments. +pub fn create( + alloc: Allocator, + opts: Options, +) !*GraphemeBreak { + const ptr = try alloc.create(GraphemeBreak); + errdefer alloc.destroy(ptr); + ptr.* = .{ .opts = opts }; + return ptr; +} + +pub fn destroy(self: *GraphemeBreak, alloc: Allocator) void { + alloc.destroy(self); +} + +pub fn benchmark(self: *GraphemeBreak) Benchmark { + return .init(self, .{ + .stepFn = switch (self.opts.mode) { + .noop => stepNoop, + .table => stepTable, + }, + .setupFn = setup, + .teardownFn = teardown, + }); +} + +fn setup(ptr: *anyopaque) Benchmark.Error!void { + const self: *GraphemeBreak = @ptrCast(@alignCast(ptr)); + + // Open our data file to prepare for reading. We can do more + // validation here eventually. + assert(self.data_f == null); + self.data_f = options.dataFile(self.opts.data) catch |err| { + log.warn("error opening data file err={}", .{err}); + return error.BenchmarkFailed; + }; +} + +fn teardown(ptr: *anyopaque) void { + const self: *GraphemeBreak = @ptrCast(@alignCast(ptr)); + if (self.data_f) |f| { + f.close(); + self.data_f = null; + } +} + +fn stepNoop(ptr: *anyopaque) Benchmark.Error!void { + const self: *GraphemeBreak = @ptrCast(@alignCast(ptr)); + + const f = self.data_f orelse return; + var r = std.io.bufferedReader(f.reader()); + var d: UTF8Decoder = .{}; + var buf: [4096]u8 = undefined; + while (true) { + const n = r.read(&buf) catch |err| { + log.warn("error reading data file err={}", .{err}); + return error.BenchmarkFailed; + }; + if (n == 0) break; // EOF reached + + for (buf[0..n]) |c| { + _ = d.next(c); + } + } +} + +fn stepTable(ptr: *anyopaque) Benchmark.Error!void { + const self: *GraphemeBreak = @ptrCast(@alignCast(ptr)); + + const f = self.data_f orelse return; + var r = std.io.bufferedReader(f.reader()); + var d: UTF8Decoder = .{}; + var state: unicode.GraphemeBreakState = .{}; + var cp1: u21 = 0; + var buf: [4096]u8 = undefined; + while (true) { + const n = r.read(&buf) catch |err| { + log.warn("error reading data file err={}", .{err}); + return error.BenchmarkFailed; + }; + if (n == 0) break; // EOF reached + + for (buf[0..n]) |c| { + const cp_, const consumed = d.next(c); + assert(consumed); + if (cp_) |cp2| { + const v = unicode.graphemeBreak(cp1, @intCast(cp2), &state); + buf[0] = @intCast(@intFromBool(v)); + cp1 = cp2; + } + } + } +} + +test GraphemeBreak { + const testing = std.testing; + const alloc = testing.allocator; + + const impl: *GraphemeBreak = try .create(alloc, .{}); + defer impl.destroy(alloc); + + const bench = impl.benchmark(); + _ = try bench.run(.once); +} diff --git a/src/benchmark/TerminalParser.zig b/src/benchmark/TerminalParser.zig new file mode 100644 index 000000000..9107d4555 --- /dev/null +++ b/src/benchmark/TerminalParser.zig @@ -0,0 +1,106 @@ +//! This benchmark tests the throughput of the terminal escape code parser. +const TerminalParser = @This(); + +const std = @import("std"); +const assert = std.debug.assert; +const Allocator = std.mem.Allocator; +const terminalpkg = @import("../terminal/main.zig"); +const Benchmark = @import("Benchmark.zig"); +const options = @import("options.zig"); + +const log = std.log.scoped(.@"terminal-stream-bench"); + +opts: Options, + +/// The file, opened in the setup function. +data_f: ?std.fs.File = null, + +pub const Options = struct { + /// The data to read as a filepath. If this is "-" then + /// we will read stdin. If this is unset, then we will + /// do nothing (benchmark is a noop). It'd be more unixy to + /// use stdin by default but I find that a hanging CLI command + /// with no interaction is a bit annoying. + data: ?[]const u8 = null, +}; + +pub fn create( + alloc: Allocator, + opts: Options, +) !*TerminalParser { + const ptr = try alloc.create(TerminalParser); + errdefer alloc.destroy(ptr); + ptr.* = .{ .opts = opts }; + return ptr; +} + +pub fn destroy(self: *TerminalParser, alloc: Allocator) void { + alloc.destroy(self); +} + +pub fn benchmark(self: *TerminalParser) Benchmark { + return .init(self, .{ + .stepFn = step, + .setupFn = setup, + .teardownFn = teardown, + }); +} + +fn setup(ptr: *anyopaque) Benchmark.Error!void { + const self: *TerminalParser = @ptrCast(@alignCast(ptr)); + + // Open our data file to prepare for reading. We can do more + // validation here eventually. + assert(self.data_f == null); + self.data_f = options.dataFile(self.opts.data) catch |err| { + log.warn("error opening data file err={}", .{err}); + return error.BenchmarkFailed; + }; +} + +fn teardown(ptr: *anyopaque) void { + const self: *TerminalParser = @ptrCast(@alignCast(ptr)); + if (self.data_f) |f| { + f.close(); + self.data_f = null; + } +} + +fn step(ptr: *anyopaque) Benchmark.Error!void { + const self: *TerminalParser = @ptrCast(@alignCast(ptr)); + + // Get our buffered reader so we're not predominantly + // waiting on file IO. It'd be better to move this fully into + // memory. If we're IO bound though that should show up on + // the benchmark results and... I know writing this that we + // aren't currently IO bound. + const f = self.data_f orelse return; + var r = std.io.bufferedReader(f.reader()); + + var p: terminalpkg.Parser = .{}; + + var buf: [4096]u8 = undefined; + while (true) { + const n = r.read(&buf) catch |err| { + log.warn("error reading data file err={}", .{err}); + return error.BenchmarkFailed; + }; + if (n == 0) break; // EOF reached + for (buf[0..n]) |c| { + const actions = p.next(c); + //std.log.warn("actions={any}", .{actions}); + _ = actions; + } + } +} + +test TerminalParser { + const testing = std.testing; + const alloc = testing.allocator; + + const impl: *TerminalParser = try .create(alloc, .{}); + defer impl.destroy(alloc); + + const bench = impl.benchmark(); + _ = try bench.run(.once); +} diff --git a/src/benchmark/cli.zig b/src/benchmark/cli.zig index b35159c6b..3f59b4a72 100644 --- a/src/benchmark/cli.zig +++ b/src/benchmark/cli.zig @@ -7,6 +7,8 @@ const cli = @import("../cli.zig"); pub const Action = enum { @"terminal-stream", @"codepoint-width", + @"grapheme-break", + @"terminal-parser", /// Returns the struct associated with the action. The struct /// should have a few decls: @@ -20,6 +22,8 @@ pub const Action = enum { return switch (action) { .@"terminal-stream" => @import("TerminalStream.zig"), .@"codepoint-width" => @import("CodepointWidth.zig"), + .@"grapheme-break" => @import("GraphemeBreak.zig"), + .@"terminal-parser" => @import("TerminalParser.zig"), }; } }; diff --git a/src/benchmark/main.zig b/src/benchmark/main.zig index dd00f72b5..56c515c9d 100644 --- a/src/benchmark/main.zig +++ b/src/benchmark/main.zig @@ -3,6 +3,8 @@ pub const Benchmark = @import("Benchmark.zig"); pub const CApi = @import("CApi.zig"); pub const TerminalStream = @import("TerminalStream.zig"); pub const CodepointWidth = @import("CodepointWidth.zig"); +pub const GraphemeBreak = @import("GraphemeBreak.zig"); +pub const TerminalParser = @import("TerminalParser.zig"); test { _ = @import("std").testing.refAllDecls(@This()); From b5ff0442d436aad2c230ce40e898dec8584fbc3a Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Wed, 9 Jul 2025 14:32:34 -0700 Subject: [PATCH 75/94] bench: remove old benchmarks we converted --- src/bench/codepoint-width.sh | 34 ------ src/bench/codepoint-width.zig | 204 ---------------------------------- src/bench/grapheme-break.sh | 33 ------ src/bench/grapheme-break.zig | 144 ------------------------ src/bench/page-init.sh | 16 --- src/bench/page-init.zig | 78 ------------- src/bench/parser.zig | 71 ------------ src/build/Config.zig | 4 - src/main.zig | 4 - 9 files changed, 588 deletions(-) delete mode 100755 src/bench/codepoint-width.sh delete mode 100644 src/bench/codepoint-width.zig delete mode 100755 src/bench/grapheme-break.sh delete mode 100644 src/bench/grapheme-break.zig delete mode 100755 src/bench/page-init.sh delete mode 100644 src/bench/page-init.zig delete mode 100644 src/bench/parser.zig diff --git a/src/bench/codepoint-width.sh b/src/bench/codepoint-width.sh deleted file mode 100755 index 43304ec2e..000000000 --- a/src/bench/codepoint-width.sh +++ /dev/null @@ -1,34 +0,0 @@ -#!/usr/bin/env bash -# -# This is a trivial helper script to help run the codepoint-width benchmark. -# You probably want to tweak this script depending on what you're -# trying to measure. - -# Options: -# - "ascii", uniform random ASCII bytes -# - "utf8", uniform random unicode characters, encoded as utf8 -# - "rand", pure random data, will contain many invalid code sequences. -DATA="utf8" -SIZE="25000000" - -# Add additional arguments -ARGS="" - -# Generate the benchmark input ahead of time so it's not included in the time. -./zig-out/bin/bench-stream --mode=gen-$DATA | head -c $SIZE > /tmp/ghostty_bench_data -#cat ~/Downloads/JAPANESEBIBLE.txt > /tmp/ghostty_bench_data - -# Uncomment to instead use the contents of `stream.txt` as input. -# yes $(cat ./stream.txt) | head -c $SIZE > /tmp/ghostty_bench_data - -hyperfine \ - --warmup 10 \ - -n noop \ - "./zig-out/bin/bench-codepoint-width --mode=noop${ARGS} try benchNoop(reader, buf), - .wcwidth => try benchWcwidth(reader, buf), - .ziglyph => try benchZiglyph(reader, buf), - .simd => try benchSimd(reader, buf), - .table => try benchTable(reader, buf), - } -} - -noinline fn benchNoop( - reader: anytype, - buf: []u8, -) !void { - var d: UTF8Decoder = .{}; - while (true) { - const n = try reader.read(buf); - if (n == 0) break; - - // Using stream.next directly with a for loop applies a naive - // scalar approach. - for (buf[0..n]) |c| { - _ = d.next(c); - } - } -} - -extern "c" fn wcwidth(c: u32) c_int; - -noinline fn benchWcwidth( - reader: anytype, - buf: []u8, -) !void { - var d: UTF8Decoder = .{}; - while (true) { - const n = try reader.read(buf); - if (n == 0) break; - - // Using stream.next directly with a for loop applies a naive - // scalar approach. - for (buf[0..n]) |c| { - const cp_, const consumed = d.next(c); - assert(consumed); - if (cp_) |cp| { - const width = wcwidth(cp); - - // Write the width to the buffer to avoid it being compiled away - buf[0] = @intCast(width); - } - } - } -} - -noinline fn benchTable( - reader: anytype, - buf: []u8, -) !void { - var d: UTF8Decoder = .{}; - while (true) { - const n = try reader.read(buf); - if (n == 0) break; - - // Using stream.next directly with a for loop applies a naive - // scalar approach. - for (buf[0..n]) |c| { - const cp_, const consumed = d.next(c); - assert(consumed); - if (cp_) |cp| { - // This is the same trick we do in terminal.zig so we - // keep it here. - const width = if (cp <= 0xFF) 1 else table.get(@intCast(cp)).width; - - // Write the width to the buffer to avoid it being compiled away - buf[0] = @intCast(width); - } - } - } -} - -noinline fn benchZiglyph( - reader: anytype, - buf: []u8, -) !void { - var d: UTF8Decoder = .{}; - while (true) { - const n = try reader.read(buf); - if (n == 0) break; - - // Using stream.next directly with a for loop applies a naive - // scalar approach. - for (buf[0..n]) |c| { - const cp_, const consumed = d.next(c); - assert(consumed); - if (cp_) |cp| { - const width = ziglyph.display_width.codePointWidth(cp, .half); - - // Write the width to the buffer to avoid it being compiled away - buf[0] = @intCast(width); - } - } - } -} - -noinline fn benchSimd( - reader: anytype, - buf: []u8, -) !void { - var d: UTF8Decoder = .{}; - while (true) { - const n = try reader.read(buf); - if (n == 0) break; - - // Using stream.next directly with a for loop applies a naive - // scalar approach. - for (buf[0..n]) |c| { - const cp_, const consumed = d.next(c); - assert(consumed); - if (cp_) |cp| { - const width = simd.codepointWidth(cp); - - // Write the width to the buffer to avoid it being compiled away - buf[0] = @intCast(width); - } - } - } -} diff --git a/src/bench/grapheme-break.sh b/src/bench/grapheme-break.sh deleted file mode 100755 index 24f475caa..000000000 --- a/src/bench/grapheme-break.sh +++ /dev/null @@ -1,33 +0,0 @@ -#!/usr/bin/env bash -# -# This is a trivial helper script to help run the grapheme-break benchmark. -# You probably want to tweak this script depending on what you're -# trying to measure. - -# Options: -# - "ascii", uniform random ASCII bytes -# - "utf8", uniform random unicode characters, encoded as utf8 -# - "rand", pure random data, will contain many invalid code sequences. -DATA="utf8" -SIZE="25000000" - -# Add additional arguments -ARGS="" - -# Generate the benchmark input ahead of time so it's not included in the time. -./zig-out/bin/bench-stream --mode=gen-$DATA | head -c $SIZE > /tmp/ghostty_bench_data -#cat ~/Downloads/JAPANESEBIBLE.txt > /tmp/ghostty_bench_data - -# Uncomment to instead use the contents of `stream.txt` as input. -# yes $(cat ./stream.txt) | head -c $SIZE > /tmp/ghostty_bench_data - -hyperfine \ - --warmup 10 \ - -n noop \ - "./zig-out/bin/bench-grapheme-break --mode=noop${ARGS} try benchNoop(reader, buf), - .ziglyph => try benchZiglyph(reader, buf), - .table => try benchTable(reader, buf), - } -} - -noinline fn benchNoop( - reader: anytype, - buf: []u8, -) !void { - var d: UTF8Decoder = .{}; - while (true) { - const n = try reader.read(buf); - if (n == 0) break; - - // Using stream.next directly with a for loop applies a naive - // scalar approach. - for (buf[0..n]) |c| { - _ = d.next(c); - } - } -} - -noinline fn benchTable( - reader: anytype, - buf: []u8, -) !void { - var d: UTF8Decoder = .{}; - var state: unicode.GraphemeBreakState = .{}; - var cp1: u21 = 0; - while (true) { - const n = try reader.read(buf); - if (n == 0) break; - - // Using stream.next directly with a for loop applies a naive - // scalar approach. - for (buf[0..n]) |c| { - const cp_, const consumed = d.next(c); - assert(consumed); - if (cp_) |cp2| { - const v = unicode.graphemeBreak(cp1, @intCast(cp2), &state); - buf[0] = @intCast(@intFromBool(v)); - cp1 = cp2; - } - } - } -} - -noinline fn benchZiglyph( - reader: anytype, - buf: []u8, -) !void { - var d: UTF8Decoder = .{}; - var state: u3 = 0; - var cp1: u21 = 0; - while (true) { - const n = try reader.read(buf); - if (n == 0) break; - - // Using stream.next directly with a for loop applies a naive - // scalar approach. - for (buf[0..n]) |c| { - const cp_, const consumed = d.next(c); - assert(consumed); - if (cp_) |cp2| { - const v = ziglyph.graphemeBreak(cp1, @intCast(cp2), &state); - buf[0] = @intCast(@intFromBool(v)); - cp1 = cp2; - } - } - } -} diff --git a/src/bench/page-init.sh b/src/bench/page-init.sh deleted file mode 100755 index 54712250b..000000000 --- a/src/bench/page-init.sh +++ /dev/null @@ -1,16 +0,0 @@ -#!/usr/bin/env bash -# -# This is a trivial helper script to help run the page init benchmark. -# You probably want to tweak this script depending on what you're -# trying to measure. - -# Uncomment to test with an active terminal state. -# ARGS=" --terminal" - -hyperfine \ - --warmup 10 \ - -n alloc \ - "./zig-out/bin/bench-page-init --mode=alloc${ARGS} try benchAlloc(args.count), - .pool => try benchPool(alloc, args.count), - } -} - -noinline fn benchAlloc(count: usize) !void { - for (0..count) |_| { - _ = try terminal_new.Page.init(terminal_new.page.std_capacity); - } -} - -noinline fn benchPool(alloc: Allocator, count: usize) !void { - var list = try terminal_new.PageList.init( - alloc, - terminal_new.page.std_capacity.cols, - terminal_new.page.std_capacity.rows, - 0, - ); - defer list.deinit(); - - for (0..count) |_| { - _ = try list.grow(); - } -} diff --git a/src/bench/parser.zig b/src/bench/parser.zig deleted file mode 100644 index 9245c06cb..000000000 --- a/src/bench/parser.zig +++ /dev/null @@ -1,71 +0,0 @@ -//! This benchmark tests the throughput of the terminal escape code parser. -//! -//! To benchmark, this takes an input stream (which is expected to come in -//! as fast as possible), runs it through the parser, and does nothing -//! with the parse result. This bottlenecks and tests the throughput of the -//! parser. -//! -//! Usage: -//! -//! "--f=" - A file to read to parse. If path is "-" then stdin -//! is read. Required. -//! - -const std = @import("std"); -const ArenaAllocator = std.heap.ArenaAllocator; -const cli = @import("../cli.zig"); -const terminal = @import("../terminal/main.zig"); - -pub fn main() !void { - // Just use a GPA - const GPA = std.heap.GeneralPurposeAllocator(.{}); - var gpa = GPA{}; - defer _ = gpa.deinit(); - const alloc = gpa.allocator(); - - // Parse our args - var args: Args = args: { - var args: Args = .{}; - errdefer args.deinit(); - var iter = try cli.args.argsIterator(alloc); - defer iter.deinit(); - try cli.args.parse(Args, alloc, &args, &iter); - break :args args; - }; - defer args.deinit(); - - // Read the file for our input - const file = file: { - if (std.mem.eql(u8, args.f, "-")) - break :file std.io.getStdIn(); - - @panic("file reading not implemented yet"); - }; - - // Read all into memory (TODO: support buffers one day) - const input = try file.reader().readAllAlloc( - alloc, - 1024 * 1024 * 1024 * 1024 * 16, // 16 GB - ); - defer alloc.free(input); - - // Run our parser - var p: terminal.Parser = .{}; - for (input) |c| { - const actions = p.next(c); - //std.log.warn("actions={any}", .{actions}); - _ = actions; - } -} - -const Args = struct { - f: []const u8 = "-", - - /// This is set by the CLI parser for deinit. - _arena: ?ArenaAllocator = null, - - pub fn deinit(self: *Args) void { - if (self._arena) |arena| arena.deinit(); - self.* = undefined; - } -}; diff --git a/src/build/Config.zig b/src/build/Config.zig index a9a79fb53..88ea8d6ac 100644 --- a/src/build/Config.zig +++ b/src/build/Config.zig @@ -528,11 +528,7 @@ pub const ExeEntrypoint = enum { webgen_config, webgen_actions, webgen_commands, - bench_parser, bench_stream, - bench_codepoint_width, - bench_grapheme_break, - bench_page_init, }; /// The release channel for the build. diff --git a/src/main.zig b/src/main.zig index 121a3b7d2..25e723c92 100644 --- a/src/main.zig +++ b/src/main.zig @@ -10,11 +10,7 @@ const entrypoint = switch (build_config.exe_entrypoint) { .webgen_config => @import("build/webgen/main_config.zig"), .webgen_actions => @import("build/webgen/main_actions.zig"), .webgen_commands => @import("build/webgen/main_commands.zig"), - .bench_parser => @import("bench/parser.zig"), .bench_stream => @import("bench/stream.zig"), - .bench_codepoint_width => @import("bench/codepoint-width.zig"), - .bench_grapheme_break => @import("bench/grapheme-break.zig"), - .bench_page_init => @import("bench/page-init.zig"), }; /// The main entrypoint for the program. From a28b7e9205b8d002119926e3251169167be595ac Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Wed, 9 Jul 2025 14:42:19 -0700 Subject: [PATCH 76/94] synthetic cli (ghostty-gen) --- src/benchmark/cli.zig | 5 +- src/benchmark/main.zig | 2 +- src/build/GhosttyBench.zig | 19 +++++++- src/main_gen.zig | 5 ++ src/synthetic/cli.zig | 95 +++++++++++++++++++++++++++++++++++++ src/synthetic/cli/Ascii.zig | 53 +++++++++++++++++++++ src/synthetic/main.zig | 2 + 7 files changed, 177 insertions(+), 4 deletions(-) create mode 100644 src/main_gen.zig create mode 100644 src/synthetic/cli.zig create mode 100644 src/synthetic/cli/Ascii.zig diff --git a/src/benchmark/cli.zig b/src/benchmark/cli.zig index 3f59b4a72..97bb9c683 100644 --- a/src/benchmark/cli.zig +++ b/src/benchmark/cli.zig @@ -3,12 +3,13 @@ const Allocator = std.mem.Allocator; const cli = @import("../cli.zig"); /// The available actions for the CLI. This is the list of available -/// benchmarks. +/// benchmarks. View docs for each individual one in the predictably +/// named files. pub const Action = enum { - @"terminal-stream", @"codepoint-width", @"grapheme-break", @"terminal-parser", + @"terminal-stream", /// Returns the struct associated with the action. The struct /// should have a few decls: diff --git a/src/benchmark/main.zig b/src/benchmark/main.zig index 56c515c9d..49bb17289 100644 --- a/src/benchmark/main.zig +++ b/src/benchmark/main.zig @@ -7,5 +7,5 @@ pub const GraphemeBreak = @import("GraphemeBreak.zig"); pub const TerminalParser = @import("TerminalParser.zig"); test { - _ = @import("std").testing.refAllDecls(@This()); + @import("std").testing.refAllDecls(@This()); } diff --git a/src/build/GhosttyBench.zig b/src/build/GhosttyBench.zig index 0dc18aa4d..0588bba3c 100644 --- a/src/build/GhosttyBench.zig +++ b/src/build/GhosttyBench.zig @@ -14,7 +14,24 @@ pub fn init( var steps = std.ArrayList(*std.Build.Step.Compile).init(b.allocator); errdefer steps.deinit(); - // Our new benchmarking application. + // Our synthetic data generator + { + const exe = b.addExecutable(.{ + .name = "ghostty-gen", + .root_module = b.createModule(.{ + .root_source_file = b.path("src/main_gen.zig"), + .target = deps.config.target, + // We always want our datagen to be fast because it + // takes awhile to run. + .optimize = .ReleaseFast, + }), + }); + exe.linkLibC(); + _ = try deps.add(exe); + try steps.append(exe); + } + + // Our benchmarking application. { const exe = b.addExecutable(.{ .name = "ghostty-bench", diff --git a/src/main_gen.zig b/src/main_gen.zig new file mode 100644 index 000000000..b988819f8 --- /dev/null +++ b/src/main_gen.zig @@ -0,0 +1,5 @@ +const std = @import("std"); +const builtin = @import("builtin"); +const synthetic = @import("synthetic/main.zig"); + +pub const main = synthetic.cli.main; diff --git a/src/synthetic/cli.zig b/src/synthetic/cli.zig new file mode 100644 index 000000000..7cb2e68d2 --- /dev/null +++ b/src/synthetic/cli.zig @@ -0,0 +1,95 @@ +const std = @import("std"); +const Allocator = std.mem.Allocator; +const cli = @import("../cli.zig"); + +/// The available actions for the CLI. This is the list of available +/// synthetic generators. View docs for each individual one in the +/// predictably named files under `cli/`. +pub const Action = enum { + ascii, + + /// Returns the struct associated with the action. The struct + /// should have a few decls: + /// + /// - `const Options`: The CLI options for the action. + /// - `fn create`: Create a new instance of the action from options. + /// - `fn destroy`: Destroy the instance of the action. + /// + /// See TerminalStream for an example. + pub fn Struct(comptime action: Action) type { + return switch (action) { + .ascii => @import("cli/Ascii.zig"), + }; + } +}; + +/// An entrypoint for the synthetic generator CLI. +pub fn main() !void { + const alloc = std.heap.c_allocator; + const action_ = try cli.action.detectArgs(Action, alloc); + const action = action_ orelse return error.NoAction; + try mainAction(alloc, action, .cli); +} + +pub const Args = union(enum) { + /// The arguments passed to the CLI via argc/argv. + cli, + + /// Simple string arguments, parsed via std.process.ArgIteratorGeneral. + string: []const u8, +}; + +pub fn mainAction( + alloc: Allocator, + action: Action, + args: Args, +) !void { + switch (action) { + inline else => |comptime_action| { + const Impl = Action.Struct(comptime_action); + try mainActionImpl(Impl, alloc, args); + }, + } +} + +fn mainActionImpl( + comptime Impl: type, + alloc: Allocator, + args: Args, +) !void { + // First, parse our CLI options. + const Options = Impl.Options; + var opts: Options = .{}; + defer if (@hasDecl(Options, "deinit")) opts.deinit(); + switch (args) { + .cli => { + var iter = try cli.args.argsIterator(alloc); + defer iter.deinit(); + try cli.args.parse(Options, alloc, &opts, &iter); + }, + .string => |str| { + var iter = try std.process.ArgIteratorGeneral(.{}).init( + alloc, + str, + ); + defer iter.deinit(); + try cli.args.parse(Options, alloc, &opts, &iter); + }, + } + + // TODO: Make this a command line option. + const seed: u64 = @truncate(@as( + u128, + @bitCast(std.time.nanoTimestamp()), + )); + var prng = std.Random.DefaultPrng.init(seed); + const rand = prng.random(); + + // Our output always goes to stdout. + const writer = std.io.getStdOut().writer(); + + // Create our implementation + const impl = try Impl.create(alloc, opts); + defer impl.destroy(alloc); + try impl.run(writer, rand); +} diff --git a/src/synthetic/cli/Ascii.zig b/src/synthetic/cli/Ascii.zig new file mode 100644 index 000000000..f294be2e0 --- /dev/null +++ b/src/synthetic/cli/Ascii.zig @@ -0,0 +1,53 @@ +//! This benchmark tests the throughput of grapheme break calculation. +//! This is a common operation in terminal character printing for terminals +//! that support grapheme clustering. +const Ascii = @This(); + +const std = @import("std"); +const assert = std.debug.assert; +const Allocator = std.mem.Allocator; +const synthetic = @import("../main.zig"); + +const log = std.log.scoped(.@"terminal-stream-bench"); + +pub const Options = struct {}; + +/// Create a new terminal stream handler for the given arguments. +pub fn create( + alloc: Allocator, + _: Options, +) !*Ascii { + const ptr = try alloc.create(Ascii); + errdefer alloc.destroy(ptr); + return ptr; +} + +pub fn destroy(self: *Ascii, alloc: Allocator) void { + alloc.destroy(self); +} + +pub fn run(self: *Ascii, writer: anytype, rand: std.Random) !void { + _ = self; + + var gen: synthetic.Bytes = .{ + .rand = rand, + .alphabet = synthetic.Bytes.Alphabet.ascii, + }; + + var buf: [1024]u8 = undefined; + while (true) { + const data = try gen.next(&buf); + writer.writeAll(data) catch |err| switch (err) { + error.BrokenPipe => return, // stdout closed + else => return err, + }; + } +} + +test Ascii { + const testing = std.testing; + const alloc = testing.allocator; + + const impl: *Ascii = try .create(alloc, .{}); + defer impl.destroy(alloc); +} diff --git a/src/synthetic/main.zig b/src/synthetic/main.zig index 67cd47054..85f9f7d35 100644 --- a/src/synthetic/main.zig +++ b/src/synthetic/main.zig @@ -13,6 +13,8 @@ //! is not limited to that and we may want to extract this to a //! standalone package one day. +pub const cli = @import("cli.zig"); + pub const Generator = @import("Generator.zig"); pub const Bytes = @import("Bytes.zig"); pub const Utf8 = @import("Utf8.zig"); From a09452bf1bfda1f719a3924a0b8cb9f9c4f87f8e Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Wed, 9 Jul 2025 15:00:48 -0700 Subject: [PATCH 77/94] synthetic: add osc/utf8 generators --- src/synthetic/cli.zig | 13 +++++++ src/synthetic/cli/Ascii.zig | 22 ++++++++---- src/synthetic/cli/Osc.zig | 67 +++++++++++++++++++++++++++++++++++++ src/synthetic/cli/Utf8.zig | 62 ++++++++++++++++++++++++++++++++++ 4 files changed, 158 insertions(+), 6 deletions(-) create mode 100644 src/synthetic/cli/Osc.zig create mode 100644 src/synthetic/cli/Utf8.zig diff --git a/src/synthetic/cli.zig b/src/synthetic/cli.zig index 7cb2e68d2..36832587c 100644 --- a/src/synthetic/cli.zig +++ b/src/synthetic/cli.zig @@ -7,6 +7,8 @@ const cli = @import("../cli.zig"); /// predictably named files under `cli/`. pub const Action = enum { ascii, + osc, + utf8, /// Returns the struct associated with the action. The struct /// should have a few decls: @@ -19,6 +21,8 @@ pub const Action = enum { pub fn Struct(comptime action: Action) type { return switch (action) { .ascii => @import("cli/Ascii.zig"), + .osc => @import("cli/Osc.zig"), + .utf8 => @import("cli/Utf8.zig"), }; } }; @@ -93,3 +97,12 @@ fn mainActionImpl( defer impl.destroy(alloc); try impl.run(writer, rand); } + +test { + // Make sure we ref all our actions + inline for (@typeInfo(Action).@"enum".fields) |field| { + const action = @field(Action, field.name); + const Impl = Action.Struct(action); + _ = Impl; + } +} diff --git a/src/synthetic/cli/Ascii.zig b/src/synthetic/cli/Ascii.zig index f294be2e0..25e5bb00b 100644 --- a/src/synthetic/cli/Ascii.zig +++ b/src/synthetic/cli/Ascii.zig @@ -1,6 +1,3 @@ -//! This benchmark tests the throughput of grapheme break calculation. -//! This is a common operation in terminal character printing for terminals -//! that support grapheme clustering. const Ascii = @This(); const std = @import("std"); @@ -37,9 +34,13 @@ pub fn run(self: *Ascii, writer: anytype, rand: std.Random) !void { var buf: [1024]u8 = undefined; while (true) { const data = try gen.next(&buf); - writer.writeAll(data) catch |err| switch (err) { - error.BrokenPipe => return, // stdout closed - else => return err, + writer.writeAll(data) catch |err| { + const Error = error{ NoSpaceLeft, BrokenPipe } || @TypeOf(err); + switch (@as(Error, err)) { + error.BrokenPipe => return, // stdout closed + error.NoSpaceLeft => return, // fixed buffer full + else => return err, + } }; } } @@ -50,4 +51,13 @@ test Ascii { const impl: *Ascii = try .create(alloc, .{}); defer impl.destroy(alloc); + + var prng = std.Random.DefaultPrng.init(1); + const rand = prng.random(); + + var buf: [1024]u8 = undefined; + var fbs = std.io.fixedBufferStream(&buf); + const writer = fbs.writer(); + + try impl.run(writer, rand); } diff --git a/src/synthetic/cli/Osc.zig b/src/synthetic/cli/Osc.zig new file mode 100644 index 000000000..4792cda6b --- /dev/null +++ b/src/synthetic/cli/Osc.zig @@ -0,0 +1,67 @@ +const Osc = @This(); + +const std = @import("std"); +const assert = std.debug.assert; +const Allocator = std.mem.Allocator; +const synthetic = @import("../main.zig"); + +const log = std.log.scoped(.@"terminal-stream-bench"); + +pub const Options = struct { + /// Probability of generating a valid value. + @"p-valid": f64 = 0.5, +}; + +opts: Options, + +/// Create a new terminal stream handler for the given arguments. +pub fn create( + alloc: Allocator, + opts: Options, +) !*Osc { + const ptr = try alloc.create(Osc); + errdefer alloc.destroy(ptr); + ptr.* = .{ .opts = opts }; + return ptr; +} + +pub fn destroy(self: *Osc, alloc: Allocator) void { + alloc.destroy(self); +} + +pub fn run(self: *Osc, writer: anytype, rand: std.Random) !void { + var gen: synthetic.Osc = .{ + .rand = rand, + .p_valid = self.opts.@"p-valid", + }; + + var buf: [1024]u8 = undefined; + while (true) { + const data = try gen.next(&buf); + writer.writeAll(data) catch |err| { + const Error = error{ NoSpaceLeft, BrokenPipe } || @TypeOf(err); + switch (@as(Error, err)) { + error.BrokenPipe => return, // stdout closed + error.NoSpaceLeft => return, // fixed buffer full + else => return err, + } + }; + } +} + +test Osc { + const testing = std.testing; + const alloc = testing.allocator; + + const impl: *Osc = try .create(alloc, .{}); + defer impl.destroy(alloc); + + var prng = std.Random.DefaultPrng.init(1); + const rand = prng.random(); + + var buf: [1024]u8 = undefined; + var fbs = std.io.fixedBufferStream(&buf); + const writer = fbs.writer(); + + try impl.run(writer, rand); +} diff --git a/src/synthetic/cli/Utf8.zig b/src/synthetic/cli/Utf8.zig new file mode 100644 index 000000000..28a11f891 --- /dev/null +++ b/src/synthetic/cli/Utf8.zig @@ -0,0 +1,62 @@ +const Utf8 = @This(); + +const std = @import("std"); +const assert = std.debug.assert; +const Allocator = std.mem.Allocator; +const synthetic = @import("../main.zig"); + +const log = std.log.scoped(.@"terminal-stream-bench"); + +pub const Options = struct {}; + +/// Create a new terminal stream handler for the given arguments. +pub fn create( + alloc: Allocator, + _: Options, +) !*Utf8 { + const ptr = try alloc.create(Utf8); + errdefer alloc.destroy(ptr); + return ptr; +} + +pub fn destroy(self: *Utf8, alloc: Allocator) void { + alloc.destroy(self); +} + +pub fn run(self: *Utf8, writer: anytype, rand: std.Random) !void { + _ = self; + + var gen: synthetic.Utf8 = .{ + .rand = rand, + }; + + var buf: [1024]u8 = undefined; + while (true) { + const data = try gen.next(&buf); + writer.writeAll(data) catch |err| { + const Error = error{ NoSpaceLeft, BrokenPipe } || @TypeOf(err); + switch (@as(Error, err)) { + error.BrokenPipe => return, // stdout closed + error.NoSpaceLeft => return, // fixed buffer full + else => return err, + } + }; + } +} + +test Utf8 { + const testing = std.testing; + const alloc = testing.allocator; + + const impl: *Utf8 = try .create(alloc, .{}); + defer impl.destroy(alloc); + + var prng = std.Random.DefaultPrng.init(1); + const rand = prng.random(); + + var buf: [1024]u8 = undefined; + var fbs = std.io.fixedBufferStream(&buf); + const writer = fbs.writer(); + + try impl.run(writer, rand); +} From 74b94ef30a26b861468db2e226cdbe38ee22da64 Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Wed, 9 Jul 2025 15:02:07 -0700 Subject: [PATCH 78/94] remove src/bench --- src/bench/stream.sh | 30 ----- src/bench/stream.zig | 253 ------------------------------------- src/build/Config.zig | 1 - src/build/GhosttyBench.zig | 48 ------- src/main.zig | 1 - 5 files changed, 333 deletions(-) delete mode 100755 src/bench/stream.sh delete mode 100644 src/bench/stream.zig diff --git a/src/bench/stream.sh b/src/bench/stream.sh deleted file mode 100755 index 38d4c37cd..000000000 --- a/src/bench/stream.sh +++ /dev/null @@ -1,30 +0,0 @@ -#!/usr/bin/env bash -# -# This is a trivial helper script to help run the stream benchmark. -# You probably want to tweak this script depending on what you're -# trying to measure. - -# Options: -# - "ascii", uniform random ASCII bytes -# - "utf8", uniform random unicode characters, encoded as utf8 -# - "rand", pure random data, will contain many invalid code sequences. -DATA="ascii" -SIZE="25000000" - -# Uncomment to test with an active terminal state. -# ARGS=" --terminal" - -# Generate the benchmark input ahead of time so it's not included in the time. -./zig-out/bin/bench-stream --mode=gen-$DATA | head -c $SIZE > /tmp/ghostty_bench_data - -# Uncomment to instead use the contents of `stream.txt` as input. (Ignores SIZE) -# echo $(cat ./stream.txt) > /tmp/ghostty_bench_data - -hyperfine \ - --warmup 10 \ - -n memcpy \ - "./zig-out/bin/bench-stream --mode=noop${ARGS} = 0) @bitCast(args.seed) else @truncate(@as(u128, @bitCast(std.time.nanoTimestamp()))); - var prng = std.Random.DefaultPrng.init(seed); - const rand = prng.random(); - - // Handle the modes that do not depend on terminal state first. - switch (args.mode) { - .@"gen-ascii" => { - var gen: synthetic.Bytes = .{ - .rand = rand, - .alphabet = synthetic.Bytes.Alphabet.ascii, - }; - try generate(writer, gen.generator()); - }, - - .@"gen-utf8" => { - var gen: synthetic.Utf8 = .{ - .rand = rand, - }; - try generate(writer, gen.generator()); - }, - - .@"gen-rand" => { - var gen: synthetic.Bytes = .{ .rand = rand }; - try generate(writer, gen.generator()); - }, - - .@"gen-osc" => { - var gen: synthetic.Osc = .{ - .rand = rand, - .p_valid = 0.5, - }; - try generate(writer, gen.generator()); - }, - - .@"gen-osc-valid" => { - var gen: synthetic.Osc = .{ - .rand = rand, - .p_valid = 1.0, - }; - try generate(writer, gen.generator()); - }, - - .@"gen-osc-invalid" => { - var gen: synthetic.Osc = .{ - .rand = rand, - .p_valid = 0.0, - }; - try generate(writer, gen.generator()); - }, - - .noop => try benchNoop(reader, buf), - - // Handle the ones that depend on terminal state next - inline .scalar, - .simd, - => |tag| switch (args.terminal) { - .new => { - const TerminalStream = terminal.Stream(*TerminalHandler); - var t = try terminal.Terminal.init(alloc, .{ - .cols = @intCast(args.@"terminal-cols"), - .rows = @intCast(args.@"terminal-rows"), - }); - var handler: TerminalHandler = .{ .t = &t }; - var stream: TerminalStream = .{ .handler = &handler }; - switch (tag) { - .scalar => try benchScalar(reader, &stream, buf), - .simd => try benchSimd(reader, &stream, buf), - else => @compileError("missing case"), - } - }, - - .none => { - var stream: terminal.Stream(NoopHandler) = .{ .handler = .{} }; - switch (tag) { - .scalar => try benchScalar(reader, &stream, buf), - .simd => try benchSimd(reader, &stream, buf), - else => @compileError("missing case"), - } - }, - }, - } -} - -fn generate( - writer: anytype, - gen: synthetic.Generator, -) !void { - var buf: [1024]u8 = undefined; - while (true) { - const data = try gen.next(&buf); - writer.writeAll(data) catch |err| switch (err) { - error.BrokenPipe => return, // stdout closed - else => return err, - }; - } -} - -noinline fn benchNoop(reader: anytype, buf: []u8) !void { - var total: usize = 0; - while (true) { - const n = try reader.readAll(buf); - if (n == 0) break; - total += n; - } - - std.log.info("total bytes len={}", .{total}); -} - -noinline fn benchScalar( - reader: anytype, - stream: anytype, - buf: []u8, -) !void { - while (true) { - const n = try reader.read(buf); - if (n == 0) break; - - // Using stream.next directly with a for loop applies a naive - // scalar approach. - for (buf[0..n]) |c| try stream.next(c); - } -} - -noinline fn benchSimd( - reader: anytype, - stream: anytype, - buf: []u8, -) !void { - while (true) { - const n = try reader.read(buf); - if (n == 0) break; - try stream.nextSlice(buf[0..n]); - } -} - -const NoopHandler = struct { - pub fn print(self: NoopHandler, cp: u21) !void { - _ = self; - _ = cp; - } -}; - -const TerminalHandler = struct { - t: *terminal.Terminal, - - pub fn print(self: *TerminalHandler, cp: u21) !void { - try self.t.print(cp); - } -}; diff --git a/src/build/Config.zig b/src/build/Config.zig index 88ea8d6ac..69a9dd8a0 100644 --- a/src/build/Config.zig +++ b/src/build/Config.zig @@ -528,7 +528,6 @@ pub const ExeEntrypoint = enum { webgen_config, webgen_actions, webgen_commands, - bench_stream, }; /// The release channel for the build. diff --git a/src/build/GhosttyBench.zig b/src/build/GhosttyBench.zig index 0588bba3c..5859a8bcf 100644 --- a/src/build/GhosttyBench.zig +++ b/src/build/GhosttyBench.zig @@ -47,54 +47,6 @@ pub fn init( try steps.append(exe); } - // Open the directory ./src/bench - const c_dir_path = b.pathFromRoot("src/bench"); - var c_dir = try std.fs.cwd().openDir(c_dir_path, .{ .iterate = true }); - defer c_dir.close(); - - // Go through and add each as a step - var c_dir_it = c_dir.iterate(); - while (try c_dir_it.next()) |entry| { - // Get the index of the last '.' so we can strip the extension. - const index = std.mem.lastIndexOfScalar(u8, entry.name, '.') orelse continue; - if (index == 0) continue; - - // If it doesn't end in 'zig' then ignore - if (!std.mem.eql(u8, entry.name[index + 1 ..], "zig")) continue; - - // Name of the conformance app and full path to the entrypoint. - const name = entry.name[0..index]; - - // Executable builder. - const bin_name = try std.fmt.allocPrint(b.allocator, "bench-{s}", .{name}); - const c_exe = b.addExecutable(.{ - .name = bin_name, - .root_module = b.createModule(.{ - .root_source_file = b.path("src/main.zig"), - .target = deps.config.target, - - // We always want our benchmarks to be in release mode. - .optimize = .ReleaseFast, - }), - }); - c_exe.linkLibC(); - - // Update our entrypoint - var enum_name: [64]u8 = undefined; - @memcpy(enum_name[0..name.len], name); - std.mem.replaceScalar(u8, enum_name[0..name.len], '-', '_'); - - var buf: [64]u8 = undefined; - const new_deps = try deps.changeEntrypoint(b, std.meta.stringToEnum( - Config.ExeEntrypoint, - try std.fmt.bufPrint(&buf, "bench_{s}", .{enum_name[0..name.len]}), - ).?); - - _ = try new_deps.add(c_exe); - - try steps.append(c_exe); - } - return .{ .steps = steps.items }; } diff --git a/src/main.zig b/src/main.zig index 25e723c92..b08e63dd2 100644 --- a/src/main.zig +++ b/src/main.zig @@ -10,7 +10,6 @@ const entrypoint = switch (build_config.exe_entrypoint) { .webgen_config => @import("build/webgen/main_config.zig"), .webgen_actions => @import("build/webgen/main_actions.zig"), .webgen_commands => @import("build/webgen/main_commands.zig"), - .bench_stream => @import("bench/stream.zig"), }; /// The main entrypoint for the program. From e962e9b5173e1afcb688733ac8eedc71fe073b90 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Thu, 10 Jul 2025 00:22:27 +0000 Subject: [PATCH 79/94] build(deps): bump cachix/install-nix-action from 31.4.1 to 31.5.0 Bumps [cachix/install-nix-action](https://github.com/cachix/install-nix-action) from 31.4.1 to 31.5.0. - [Release notes](https://github.com/cachix/install-nix-action/releases) - [Changelog](https://github.com/cachix/install-nix-action/blob/master/RELEASE.md) - [Commits](https://github.com/cachix/install-nix-action/compare/f0fe604f8a612776892427721526b4c7cfb23aba...cebd211ec2008b83bda8fb0b21c3c072f004fe04) --- updated-dependencies: - dependency-name: cachix/install-nix-action dependency-version: 31.5.0 dependency-type: direct:production update-type: version-update:semver-minor ... Signed-off-by: dependabot[bot] --- .github/workflows/nix.yml | 2 +- .github/workflows/release-pr.yml | 4 +-- .github/workflows/release-tag.yml | 4 +-- .github/workflows/release-tip.yml | 8 ++--- .github/workflows/test.yml | 42 +++++++++++------------ .github/workflows/update-colorschemes.yml | 2 +- 6 files changed, 31 insertions(+), 31 deletions(-) diff --git a/.github/workflows/nix.yml b/.github/workflows/nix.yml index bf8fd7208..ab881809c 100644 --- a/.github/workflows/nix.yml +++ b/.github/workflows/nix.yml @@ -42,7 +42,7 @@ jobs: /nix /zig - name: Setup Nix - uses: cachix/install-nix-action@f0fe604f8a612776892427721526b4c7cfb23aba # v31.4.1 + uses: cachix/install-nix-action@cebd211ec2008b83bda8fb0b21c3c072f004fe04 # v31.5.0 with: nix_path: nixpkgs=channel:nixos-unstable - uses: cachix/cachix-action@0fc020193b5a1fa3ac4575aa3a7d3aa6a35435ad # v16 diff --git a/.github/workflows/release-pr.yml b/.github/workflows/release-pr.yml index e260996bb..7f48d109f 100644 --- a/.github/workflows/release-pr.yml +++ b/.github/workflows/release-pr.yml @@ -57,7 +57,7 @@ jobs: fetch-depth: 0 # Install Nix and use that to run our tests so our environment matches exactly. - - uses: cachix/install-nix-action@f0fe604f8a612776892427721526b4c7cfb23aba # v31.4.1 + - uses: cachix/install-nix-action@cebd211ec2008b83bda8fb0b21c3c072f004fe04 # v31.5.0 with: nix_path: nixpkgs=channel:nixos-unstable - uses: cachix/cachix-action@0fc020193b5a1fa3ac4575aa3a7d3aa6a35435ad # v16 @@ -211,7 +211,7 @@ jobs: fetch-depth: 0 # Install Nix and use that to run our tests so our environment matches exactly. - - uses: cachix/install-nix-action@f0fe604f8a612776892427721526b4c7cfb23aba # v31.4.1 + - uses: cachix/install-nix-action@cebd211ec2008b83bda8fb0b21c3c072f004fe04 # v31.5.0 with: nix_path: nixpkgs=channel:nixos-unstable - uses: cachix/cachix-action@0fc020193b5a1fa3ac4575aa3a7d3aa6a35435ad # v16 diff --git a/.github/workflows/release-tag.yml b/.github/workflows/release-tag.yml index 4cc364127..ee0b389c1 100644 --- a/.github/workflows/release-tag.yml +++ b/.github/workflows/release-tag.yml @@ -89,7 +89,7 @@ jobs: /nix /zig - - uses: cachix/install-nix-action@f0fe604f8a612776892427721526b4c7cfb23aba # v31.4.1 + - uses: cachix/install-nix-action@cebd211ec2008b83bda8fb0b21c3c072f004fe04 # v31.5.0 with: nix_path: nixpkgs=channel:nixos-unstable @@ -130,7 +130,7 @@ jobs: - name: Checkout code uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 - - uses: cachix/install-nix-action@f0fe604f8a612776892427721526b4c7cfb23aba # v31.4.1 + - uses: cachix/install-nix-action@cebd211ec2008b83bda8fb0b21c3c072f004fe04 # v31.5.0 with: nix_path: nixpkgs=channel:nixos-unstable - uses: cachix/cachix-action@0fc020193b5a1fa3ac4575aa3a7d3aa6a35435ad # v16 diff --git a/.github/workflows/release-tip.yml b/.github/workflows/release-tip.yml index b7c4949a5..941aed6e5 100644 --- a/.github/workflows/release-tip.yml +++ b/.github/workflows/release-tip.yml @@ -112,7 +112,7 @@ jobs: path: | /nix /zig - - uses: cachix/install-nix-action@f0fe604f8a612776892427721526b4c7cfb23aba # v31.4.1 + - uses: cachix/install-nix-action@cebd211ec2008b83bda8fb0b21c3c072f004fe04 # v31.5.0 with: nix_path: nixpkgs=channel:nixos-unstable - uses: cachix/cachix-action@0fc020193b5a1fa3ac4575aa3a7d3aa6a35435ad # v16 @@ -164,7 +164,7 @@ jobs: fetch-depth: 0 # Install Nix and use that to run our tests so our environment matches exactly. - - uses: cachix/install-nix-action@f0fe604f8a612776892427721526b4c7cfb23aba # v31.4.1 + - uses: cachix/install-nix-action@cebd211ec2008b83bda8fb0b21c3c072f004fe04 # v31.5.0 with: nix_path: nixpkgs=channel:nixos-unstable - uses: cachix/cachix-action@0fc020193b5a1fa3ac4575aa3a7d3aa6a35435ad # v16 @@ -381,7 +381,7 @@ jobs: fetch-depth: 0 # Install Nix and use that to run our tests so our environment matches exactly. - - uses: cachix/install-nix-action@f0fe604f8a612776892427721526b4c7cfb23aba # v31.4.1 + - uses: cachix/install-nix-action@cebd211ec2008b83bda8fb0b21c3c072f004fe04 # v31.5.0 with: nix_path: nixpkgs=channel:nixos-unstable - uses: cachix/cachix-action@0fc020193b5a1fa3ac4575aa3a7d3aa6a35435ad # v16 @@ -558,7 +558,7 @@ jobs: fetch-depth: 0 # Install Nix and use that to run our tests so our environment matches exactly. - - uses: cachix/install-nix-action@f0fe604f8a612776892427721526b4c7cfb23aba # v31.4.1 + - uses: cachix/install-nix-action@cebd211ec2008b83bda8fb0b21c3c072f004fe04 # v31.5.0 with: nix_path: nixpkgs=channel:nixos-unstable - uses: cachix/cachix-action@0fc020193b5a1fa3ac4575aa3a7d3aa6a35435ad # v16 diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 834d49a5c..cf3d983c0 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -75,7 +75,7 @@ jobs: /zig # Install Nix and use that to run our tests so our environment matches exactly. - - uses: cachix/install-nix-action@f0fe604f8a612776892427721526b4c7cfb23aba # v31.4.1 + - uses: cachix/install-nix-action@cebd211ec2008b83bda8fb0b21c3c072f004fe04 # v31.5.0 with: nix_path: nixpkgs=channel:nixos-unstable - uses: cachix/cachix-action@0fc020193b5a1fa3ac4575aa3a7d3aa6a35435ad # v16 @@ -106,7 +106,7 @@ jobs: /zig # Install Nix and use that to run our tests so our environment matches exactly. - - uses: cachix/install-nix-action@f0fe604f8a612776892427721526b4c7cfb23aba # v31.4.1 + - uses: cachix/install-nix-action@cebd211ec2008b83bda8fb0b21c3c072f004fe04 # v31.5.0 with: nix_path: nixpkgs=channel:nixos-unstable - uses: cachix/cachix-action@0fc020193b5a1fa3ac4575aa3a7d3aa6a35435ad # v16 @@ -142,7 +142,7 @@ jobs: /zig # Install Nix and use that to run our tests so our environment matches exactly. - - uses: cachix/install-nix-action@f0fe604f8a612776892427721526b4c7cfb23aba # v31.4.1 + - uses: cachix/install-nix-action@cebd211ec2008b83bda8fb0b21c3c072f004fe04 # v31.5.0 with: nix_path: nixpkgs=channel:nixos-unstable - uses: cachix/cachix-action@0fc020193b5a1fa3ac4575aa3a7d3aa6a35435ad # v16 @@ -171,7 +171,7 @@ jobs: /zig # Install Nix and use that to run our tests so our environment matches exactly. - - uses: cachix/install-nix-action@f0fe604f8a612776892427721526b4c7cfb23aba # v31.4.1 + - uses: cachix/install-nix-action@cebd211ec2008b83bda8fb0b21c3c072f004fe04 # v31.5.0 with: nix_path: nixpkgs=channel:nixos-unstable - uses: cachix/cachix-action@0fc020193b5a1fa3ac4575aa3a7d3aa6a35435ad # v16 @@ -204,7 +204,7 @@ jobs: /zig # Install Nix and use that to run our tests so our environment matches exactly. - - uses: cachix/install-nix-action@f0fe604f8a612776892427721526b4c7cfb23aba # v31.4.1 + - uses: cachix/install-nix-action@cebd211ec2008b83bda8fb0b21c3c072f004fe04 # v31.5.0 with: nix_path: nixpkgs=channel:nixos-unstable - uses: cachix/cachix-action@0fc020193b5a1fa3ac4575aa3a7d3aa6a35435ad # v16 @@ -248,7 +248,7 @@ jobs: /zig # Install Nix and use that to run our tests so our environment matches exactly. - - uses: cachix/install-nix-action@f0fe604f8a612776892427721526b4c7cfb23aba # v31.4.1 + - uses: cachix/install-nix-action@cebd211ec2008b83bda8fb0b21c3c072f004fe04 # v31.5.0 with: nix_path: nixpkgs=channel:nixos-unstable - uses: cachix/cachix-action@0fc020193b5a1fa3ac4575aa3a7d3aa6a35435ad # v16 @@ -277,7 +277,7 @@ jobs: uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 # Install Nix and use that to run our tests so our environment matches exactly. - - uses: cachix/install-nix-action@f0fe604f8a612776892427721526b4c7cfb23aba # v31.4.1 + - uses: cachix/install-nix-action@cebd211ec2008b83bda8fb0b21c3c072f004fe04 # v31.5.0 with: nix_path: nixpkgs=channel:nixos-unstable - uses: cachix/cachix-action@0fc020193b5a1fa3ac4575aa3a7d3aa6a35435ad # v16 @@ -357,7 +357,7 @@ jobs: uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 # Install Nix and use that to run our tests so our environment matches exactly. - - uses: cachix/install-nix-action@f0fe604f8a612776892427721526b4c7cfb23aba # v31.4.1 + - uses: cachix/install-nix-action@cebd211ec2008b83bda8fb0b21c3c072f004fe04 # v31.5.0 with: nix_path: nixpkgs=channel:nixos-unstable - uses: cachix/cachix-action@0fc020193b5a1fa3ac4575aa3a7d3aa6a35435ad # v16 @@ -511,7 +511,7 @@ jobs: /zig # Install Nix and use that to run our tests so our environment matches exactly. - - uses: cachix/install-nix-action@f0fe604f8a612776892427721526b4c7cfb23aba # v31.4.1 + - uses: cachix/install-nix-action@cebd211ec2008b83bda8fb0b21c3c072f004fe04 # v31.5.0 with: nix_path: nixpkgs=channel:nixos-unstable - uses: cachix/cachix-action@0fc020193b5a1fa3ac4575aa3a7d3aa6a35435ad # v16 @@ -553,7 +553,7 @@ jobs: /zig # Install Nix and use that to run our tests so our environment matches exactly. - - uses: cachix/install-nix-action@f0fe604f8a612776892427721526b4c7cfb23aba # v31.4.1 + - uses: cachix/install-nix-action@cebd211ec2008b83bda8fb0b21c3c072f004fe04 # v31.5.0 with: nix_path: nixpkgs=channel:nixos-unstable - uses: cachix/cachix-action@0fc020193b5a1fa3ac4575aa3a7d3aa6a35435ad # v16 @@ -601,7 +601,7 @@ jobs: /zig # Install Nix and use that to run our tests so our environment matches exactly. - - uses: cachix/install-nix-action@f0fe604f8a612776892427721526b4c7cfb23aba # v31.4.1 + - uses: cachix/install-nix-action@cebd211ec2008b83bda8fb0b21c3c072f004fe04 # v31.5.0 with: nix_path: nixpkgs=channel:nixos-unstable - uses: cachix/cachix-action@0fc020193b5a1fa3ac4575aa3a7d3aa6a35435ad # v16 @@ -621,7 +621,7 @@ jobs: uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 # Install Nix and use that to run our tests so our environment matches exactly. - - uses: cachix/install-nix-action@f0fe604f8a612776892427721526b4c7cfb23aba # v31.4.1 + - uses: cachix/install-nix-action@cebd211ec2008b83bda8fb0b21c3c072f004fe04 # v31.5.0 with: nix_path: nixpkgs=channel:nixos-unstable - uses: cachix/cachix-action@0fc020193b5a1fa3ac4575aa3a7d3aa6a35435ad # v16 @@ -654,7 +654,7 @@ jobs: path: | /nix /zig - - uses: cachix/install-nix-action@f0fe604f8a612776892427721526b4c7cfb23aba # v31.4.1 + - uses: cachix/install-nix-action@cebd211ec2008b83bda8fb0b21c3c072f004fe04 # v31.5.0 with: nix_path: nixpkgs=channel:nixos-unstable - uses: cachix/cachix-action@0fc020193b5a1fa3ac4575aa3a7d3aa6a35435ad # v16 @@ -682,7 +682,7 @@ jobs: path: | /nix /zig - - uses: cachix/install-nix-action@f0fe604f8a612776892427721526b4c7cfb23aba # v31.4.1 + - uses: cachix/install-nix-action@cebd211ec2008b83bda8fb0b21c3c072f004fe04 # v31.5.0 with: nix_path: nixpkgs=channel:nixos-unstable - uses: cachix/cachix-action@0fc020193b5a1fa3ac4575aa3a7d3aa6a35435ad # v16 @@ -709,7 +709,7 @@ jobs: path: | /nix /zig - - uses: cachix/install-nix-action@f0fe604f8a612776892427721526b4c7cfb23aba # v31.4.1 + - uses: cachix/install-nix-action@cebd211ec2008b83bda8fb0b21c3c072f004fe04 # v31.5.0 with: nix_path: nixpkgs=channel:nixos-unstable - uses: cachix/cachix-action@0fc020193b5a1fa3ac4575aa3a7d3aa6a35435ad # v16 @@ -736,7 +736,7 @@ jobs: path: | /nix /zig - - uses: cachix/install-nix-action@f0fe604f8a612776892427721526b4c7cfb23aba # v31.4.1 + - uses: cachix/install-nix-action@cebd211ec2008b83bda8fb0b21c3c072f004fe04 # v31.5.0 with: nix_path: nixpkgs=channel:nixos-unstable - uses: cachix/cachix-action@0fc020193b5a1fa3ac4575aa3a7d3aa6a35435ad # v16 @@ -763,7 +763,7 @@ jobs: path: | /nix /zig - - uses: cachix/install-nix-action@f0fe604f8a612776892427721526b4c7cfb23aba # v31.4.1 + - uses: cachix/install-nix-action@cebd211ec2008b83bda8fb0b21c3c072f004fe04 # v31.5.0 with: nix_path: nixpkgs=channel:nixos-unstable - uses: cachix/cachix-action@0fc020193b5a1fa3ac4575aa3a7d3aa6a35435ad # v16 @@ -790,7 +790,7 @@ jobs: path: | /nix /zig - - uses: cachix/install-nix-action@f0fe604f8a612776892427721526b4c7cfb23aba # v31.4.1 + - uses: cachix/install-nix-action@cebd211ec2008b83bda8fb0b21c3c072f004fe04 # v31.5.0 with: nix_path: nixpkgs=channel:nixos-unstable - uses: cachix/cachix-action@0fc020193b5a1fa3ac4575aa3a7d3aa6a35435ad # v16 @@ -817,7 +817,7 @@ jobs: path: | /nix /zig - - uses: cachix/install-nix-action@f0fe604f8a612776892427721526b4c7cfb23aba # v31.4.1 + - uses: cachix/install-nix-action@cebd211ec2008b83bda8fb0b21c3c072f004fe04 # v31.5.0 with: nix_path: nixpkgs=channel:nixos-unstable - uses: cachix/cachix-action@0fc020193b5a1fa3ac4575aa3a7d3aa6a35435ad # v16 @@ -854,7 +854,7 @@ jobs: /zig # Install Nix and use that to run our tests so our environment matches exactly. - - uses: cachix/install-nix-action@f0fe604f8a612776892427721526b4c7cfb23aba # v31.4.1 + - uses: cachix/install-nix-action@cebd211ec2008b83bda8fb0b21c3c072f004fe04 # v31.5.0 with: nix_path: nixpkgs=channel:nixos-unstable - uses: cachix/cachix-action@0fc020193b5a1fa3ac4575aa3a7d3aa6a35435ad # v16 @@ -911,7 +911,7 @@ jobs: /nix /zig - name: Setup Nix - uses: cachix/install-nix-action@f0fe604f8a612776892427721526b4c7cfb23aba # v31.4.1 + uses: cachix/install-nix-action@cebd211ec2008b83bda8fb0b21c3c072f004fe04 # v31.5.0 with: nix_path: nixpkgs=channel:nixos-unstable - uses: cachix/cachix-action@0fc020193b5a1fa3ac4575aa3a7d3aa6a35435ad # v16 diff --git a/.github/workflows/update-colorschemes.yml b/.github/workflows/update-colorschemes.yml index b9ded559e..5392a9e11 100644 --- a/.github/workflows/update-colorschemes.yml +++ b/.github/workflows/update-colorschemes.yml @@ -29,7 +29,7 @@ jobs: /zig - name: Setup Nix - uses: cachix/install-nix-action@f0fe604f8a612776892427721526b4c7cfb23aba # v31.4.1 + uses: cachix/install-nix-action@cebd211ec2008b83bda8fb0b21c3c072f004fe04 # v31.5.0 with: nix_path: nixpkgs=channel:nixos-unstable - uses: cachix/cachix-action@0fc020193b5a1fa3ac4575aa3a7d3aa6a35435ad # v16 From 5cdfe3d70e40fb7f4b2f0ed5ed710ab7d887ae01 Mon Sep 17 00:00:00 2001 From: Jon Parise Date: Wed, 9 Jul 2025 21:44:32 -0400 Subject: [PATCH 80/94] elvish: revise the ssh integration The previous implementation wasn't quite working. This revision reworks it in a few ways: - Fix various syntax issues - Redirect the `ssh` command to our 'ssh-integration' function - Locate the `ghostty` binary using $GHOSTTY_BIN_DIR - Use os:temp-dir to create our temporary directory Also, consistently use 2-space indents, which is the Elvish standard. --- .editorconfig | 2 +- .../elvish/lib/ghostty-integration.elv | 160 +++++++++--------- 2 files changed, 77 insertions(+), 85 deletions(-) diff --git a/.editorconfig b/.editorconfig index d305bd294..4e9bec6ce 100644 --- a/.editorconfig +++ b/.editorconfig @@ -1,6 +1,6 @@ root = true -[*.{sh,bash}] +[*.{sh,bash,elv}] indent_size = 2 indent_style = space diff --git a/src/shell-integration/elvish/lib/ghostty-integration.elv b/src/shell-integration/elvish/lib/ghostty-integration.elv index 4e95b251f..6d0d19f4f 100644 --- a/src/shell-integration/elvish/lib/ghostty-integration.elv +++ b/src/shell-integration/elvish/lib/ghostty-integration.elv @@ -38,6 +38,9 @@ { use str + # List of enabled shell integration features + var features = [(str:split ',' $E:GHOSTTY_SHELL_FEATURES)] + # helper used by `mark-*` functions fn set-prompt-state {|new| set-env __ghostty_prompt_state $new } @@ -98,93 +101,81 @@ (external sudo) $@args } - # SSH Integration - use str + fn ssh-integration {|@args| + var ssh-term = "xterm-256color" + var ssh-opts = [] - if (str:contains $E:GHOSTTY_SHELL_FEATURES ssh-) { - fn ssh {|@args| - var ssh-term = "xterm-256color" - var ssh-opts = [] + # Configure environment variables for remote session + if (has-value $features ssh-env) { + set ssh-opts = (conj $ssh-opts ^ + -o "SetEnv COLORTERM=truecolor" ^ + -o "SendEnv TERM_PROGRAM TERM_PROGRAM_VERSION") + } - # Configure environment variables for remote session - if (str:contains $E:GHOSTTY_SHELL_FEATURES ssh-env) { - set ssh-opts = (conj $ssh-opts - -o "SetEnv COLORTERM=truecolor" - -o "SendEnv TERM_PROGRAM TERM_PROGRAM_VERSION" - ) + if (has-value $features ssh-terminfo) { + var ssh-user = "" + var ssh-hostname = "" + + # Parse ssh config + for line [((external ssh) -G $@args)] { + var parts = [(str:fields $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 } - - # Install terminfo on remote host if needed - if (str:contains $E:GHOSTTY_SHELL_FEATURES ssh-terminfo) { - var ssh-user = "" - var ssh-hostname = "" - - # 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 - } - } - } - - if (not-eq $ssh-hostname "") { - var ssh-target = $ssh-user"@"$ssh-hostname - - # Check if terminfo is already cached - if (and (has-external ghostty) (bool ?(external ghostty +ssh-cache --host=$ssh-target >/dev/null 2>&1))) { - set ssh-term = "xterm-ghostty" - } 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-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 (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) - - # Cache successful installation - if (has-external ghostty) { - external ghostty +ssh-cache --add=$ssh-target >/dev/null 2>&1 - } - } else { - echo "Warning: Failed to install terminfo." >&2 - } - } else { - echo "Warning: Could not generate terminfo data." >&2 - } - } else { - echo "Warning: ghostty command not available for cache management." >&2 - } - } + if (and (not-eq $ssh-user "") (not-eq $ssh-hostname "")) { + break } - - # Execute SSH with TERM environment variable - external E:TERM=$ssh-term ssh $@ssh-opts $@args + } } + + if (not-eq $ssh-hostname "") { + var ghostty = $E:GHOSTTY_BIN_DIR/"ghostty" + var ssh-target = $ssh-user"@"$ssh-hostname + + # Check if terminfo is already cached + if (bool ?($ghostty +ssh-cache --host=$ssh-target)) { + set ssh-term = "xterm-ghostty" + } 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 + + use os + var ssh-cpath-dir = (os:temp-dir "ghostty-ssh-"$ssh-user".*") + var ssh-cpath = $ssh-cpath-dir"/socket" + + 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) + + # Cache successful installation + $ghostty +ssh-cache --add=$ssh-target >/dev/null + } else { + echo "Warning: Failed to install terminfo." >&2 + } + } else { + echo "Warning: Could not generate terminfo data." >&2 + } + } else { + echo "Warning: ghostty command not available for cache management." >&2 + } + } + } + + with [E:TERM = $ssh-term] { + (external ssh) $@ssh-opts $@args + } } defer { @@ -196,8 +187,6 @@ set edit:after-readline = (conj $edit:after-readline $mark-output-start~) set edit:after-command = (conj $edit:after-command $mark-output-end~) - var features = [(str:split ',' $E:GHOSTTY_SHELL_FEATURES)] - if (has-value $features title) { set after-chdir = (conj $after-chdir {|_| report-pwd }) } @@ -210,4 +199,7 @@ if (and (has-value $features sudo) (not-eq "" $E:TERMINFO) (has-external sudo)) { edit:add-var sudo~ $sudo-with-terminfo~ } + if (and (str:contains $E:GHOSTTY_SHELL_FEATURES ssh-) (has-external ssh)) { + edit:add-var ssh~ $ssh-integration~ + } } From 88736a2ddb6ab4b0f69d569201248728d6099f14 Mon Sep 17 00:00:00 2001 From: ClearAspect Date: Thu, 10 Jul 2025 00:08:37 -0400 Subject: [PATCH 81/94] Fix custom shader cursor uniforms not set for non-block cursors (#7893) Fixes #7893 Previously, custom shader cursor uniforms were only updated when the cursor glyph was in the front (block) cursor list. This caused non-block cursors (such as bar, underline, hollow block, and lock) to be missing from custom shader effects. This commit adds a helper to the cell contents struct to retrieve the current cursor glyph from either the front or back cursor lists, and updates the renderer to use this helper when setting custom shader uniforms. As a result, custom shaders now receive correct cursor information for all supported cursor styles. --- src/renderer/cell.zig | 11 +++++++++++ src/renderer/generic.zig | 5 +---- 2 files changed, 12 insertions(+), 4 deletions(-) diff --git a/src/renderer/cell.zig b/src/renderer/cell.zig index 43d744176..b8b218d99 100644 --- a/src/renderer/cell.zig +++ b/src/renderer/cell.zig @@ -156,6 +156,17 @@ pub const Contents = struct { } } + /// Returns the current cursor glyph if present, checking both cursor lists. + pub fn getCursorGlyph(self: *Contents) ?shaderpkg.CellText { + if (self.fg_rows.lists[0].items.len > 0) { + return self.fg_rows.lists[0].items[0]; + } + if (self.fg_rows.lists[self.size.rows + 1].items.len > 0) { + return self.fg_rows.lists[self.size.rows + 1].items[0]; + } + return null; + } + /// Access a background cell. Prefer this function over direct indexing /// of `bg_cells` in order to avoid integer size bugs causing overflows. pub inline fn bgCell( diff --git a/src/renderer/generic.zig b/src/renderer/generic.zig index 3965d302a..2374ec1b0 100644 --- a/src/renderer/generic.zig +++ b/src/renderer/generic.zig @@ -2218,10 +2218,7 @@ pub fn Renderer(comptime GraphicsAPI: type) type { }; // Update custom cursor uniforms, if we have a cursor. - if (self.cells.fg_rows.lists[0].items.len > 0) { - const cursor: shaderpkg.CellText = - self.cells.fg_rows.lists[0].items[0]; - + if (self.cells.getCursorGlyph()) |cursor| { const cursor_width: f32 = @floatFromInt(cursor.glyph_size[0]); const cursor_height: f32 = @floatFromInt(cursor.glyph_size[1]); From 6744e57c68589249cb9ef3a725b0391b42177d46 Mon Sep 17 00:00:00 2001 From: Qwerasd Date: Wed, 9 Jul 2025 22:27:50 -0600 Subject: [PATCH 82/94] fix(terminal/PageList): update viewport in row count resize Before, if the row count increase past the active area then we added new rows to make sure that we had enough for the active area, but we didn't make sure that the viewport pin wasn't below the active area pin, which meant that later on if someone tried to get the bottom right pin for the viewport it would overshoot and we'd use null. This resulted in either a memory corruption bug in ReleaseFast if you scaled down the font while scrolled up slightly, or in Debug mode it was just a crash. --- src/terminal/PageList.zig | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/src/terminal/PageList.zig b/src/terminal/PageList.zig index 9838bfb53..d13cd7fef 100644 --- a/src/terminal/PageList.zig +++ b/src/terminal/PageList.zig @@ -1401,6 +1401,15 @@ fn resizeWithoutReflow(self: *PageList, opts: Resize) !void { assert(count < rows); for (count..rows) |_| _ = try self.grow(); } + + // Make sure that the viewport pin isn't below the active + // area, since that will lead to all sorts of problems. + switch (self.viewport) { + .pin => if (self.pinIsActive(self.viewport_pin.*)) { + self.viewport = .{ .active = {} }; + }, + .active, .top => {}, + } }, } From cc0d7acaefa7495a04c287dee65d06304c204024 Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Wed, 9 Jul 2025 21:05:54 -0700 Subject: [PATCH 83/94] build: make the xcframework step dsym aware, even though we don't use it This was in pursuit of trying to get line numbers in `zig build run` on macOS to work, but I wasn't able to figure that out and this wasn't the right path because static libs can't have dsyms. But, it may still be useful to make the xcframework step dsym aware for future use so I'm PRing this. --- src/build/GhosttyLib.zig | 48 +++++++++++++++++++++++++++----- src/build/GhosttyXCFramework.zig | 4 +++ src/build/XCFrameworkStep.zig | 7 +++++ 3 files changed, 52 insertions(+), 7 deletions(-) diff --git a/src/build/GhosttyLib.zig b/src/build/GhosttyLib.zig index 4e36c57c8..857fd1798 100644 --- a/src/build/GhosttyLib.zig +++ b/src/build/GhosttyLib.zig @@ -1,6 +1,7 @@ const GhosttyLib = @This(); const std = @import("std"); +const RunStep = std.Build.Step.Run; const Config = @import("Config.zig"); const SharedDeps = @import("SharedDeps.zig"); const LibtoolStep = @import("LibtoolStep.zig"); @@ -11,6 +12,7 @@ step: *std.Build.Step, /// The final static library file output: std.Build.LazyPath, +dsym: ?std.Build.LazyPath, pub fn initStatic( b: *std.Build, @@ -18,9 +20,14 @@ pub fn initStatic( ) !GhosttyLib { const lib = b.addStaticLibrary(.{ .name = "ghostty", - .root_source_file = b.path("src/main_c.zig"), - .target = deps.config.target, - .optimize = deps.config.optimize, + .root_module = b.createModule(.{ + .root_source_file = b.path("src/main_c.zig"), + .target = deps.config.target, + .optimize = deps.config.optimize, + .strip = deps.config.strip, + .omit_frame_pointer = deps.config.strip, + .unwind_tables = if (deps.config.strip) .none else .sync, + }), }); lib.linkLibC(); @@ -37,6 +44,7 @@ pub fn initStatic( if (!deps.config.target.result.os.tag.isDarwin()) return .{ .step = &lib.step, .output = lib.getEmittedBin(), + .dsym = null, }; // Create a static lib that contains all our dependencies. @@ -50,6 +58,9 @@ pub fn initStatic( return .{ .step = libtool.step, .output = libtool.output, + + // Static libraries cannot have dSYMs because they aren't linked. + .dsym = null, }; } @@ -59,16 +70,35 @@ pub fn initShared( ) !GhosttyLib { const lib = b.addSharedLibrary(.{ .name = "ghostty", - .root_source_file = b.path("src/main_c.zig"), - .target = deps.config.target, - .optimize = deps.config.optimize, - .strip = deps.config.strip, + .root_module = b.createModule(.{ + .root_source_file = b.path("src/main_c.zig"), + .target = deps.config.target, + .optimize = deps.config.optimize, + .strip = deps.config.strip, + .omit_frame_pointer = deps.config.strip, + .unwind_tables = if (deps.config.strip) .none else .sync, + }), }); _ = try deps.add(lib); + // Get our debug symbols + const dsymutil: ?std.Build.LazyPath = dsymutil: { + if (!deps.config.target.result.os.tag.isDarwin()) { + break :dsymutil null; + } + + const dsymutil = RunStep.create(b, "dsymutil"); + dsymutil.addArgs(&.{"dsymutil"}); + dsymutil.addFileArg(lib.getEmittedBin()); + dsymutil.addArgs(&.{"-o"}); + const output = dsymutil.addOutputFileArg("libghostty.dSYM"); + break :dsymutil output; + }; + return .{ .step = &lib.step, .output = lib.getEmittedBin(), + .dsym = dsymutil, }; } @@ -95,6 +125,10 @@ pub fn initMacOSUniversal( return .{ .step = universal.step, .output = universal.output, + + // You can't run dsymutil on a universal binary, you have to + // do it on the individual binaries. + .dsym = null, }; } diff --git a/src/build/GhosttyXCFramework.zig b/src/build/GhosttyXCFramework.zig index 7debd6906..d036e7020 100644 --- a/src/build/GhosttyXCFramework.zig +++ b/src/build/GhosttyXCFramework.zig @@ -64,20 +64,24 @@ pub fn init( .{ .library = macos_universal.output, .headers = b.path("include"), + .dsym = macos_universal.dsym, }, .{ .library = ios.output, .headers = b.path("include"), + .dsym = ios.dsym, }, .{ .library = ios_sim.output, .headers = b.path("include"), + .dsym = ios_sim.dsym, }, }, .native => &.{.{ .library = macos_native.output, .headers = b.path("include"), + .dsym = macos_native.dsym, }}, }, }); diff --git a/src/build/XCFrameworkStep.zig b/src/build/XCFrameworkStep.zig index 8a0d5dc67..39f0f9bac 100644 --- a/src/build/XCFrameworkStep.zig +++ b/src/build/XCFrameworkStep.zig @@ -26,6 +26,9 @@ pub const Library = struct { /// Path to a directory with the headers. headers: LazyPath, + + /// Path to a debug symbols file (.dSYM) if available. + dsym: ?LazyPath, }; step: *Step, @@ -52,6 +55,10 @@ pub fn create(b: *std.Build, opts: Options) *XCFrameworkStep { run.addFileArg(lib.library); run.addArg("-headers"); run.addFileArg(lib.headers); + if (lib.dsym) |dsym| { + run.addArg("-debug-symbols"); + run.addFileArg(dsym); + } } run.addArg("-output"); run.addArg(opts.out_path); From ea4a056d34783885b7299202c68e3f18cc4dd799 Mon Sep 17 00:00:00 2001 From: Qwerasd Date: Wed, 9 Jul 2025 22:47:01 -0600 Subject: [PATCH 84/94] test(terminal/PageList): resize keeps viewport <= active This tests for the bug fixed in the last commit. --- src/terminal/PageList.zig | 30 ++++++++++++++++++++++++++++++ 1 file changed, 30 insertions(+) diff --git a/src/terminal/PageList.zig b/src/terminal/PageList.zig index d13cd7fef..660949c9c 100644 --- a/src/terminal/PageList.zig +++ b/src/terminal/PageList.zig @@ -5984,6 +5984,36 @@ test "PageList resize (no reflow) more rows extends blank lines" { } } +test "PageList resize (no reflow) more rows contains viewport" { + const testing = std.testing; + const alloc = testing.allocator; + + // When the rows are increased we need to make sure that the viewport + // doesn't end up below the active area if it's currently in pin mode. + + var s = try init(alloc, 5, 5, 1); + defer s.deinit(); + try testing.expect(s.pages.first == s.pages.last); + + // Make it so we have scrollback + _ = try s.grow(); + + try testing.expectEqual(@as(usize, 5), s.rows); + try testing.expectEqual(@as(usize, 6), s.totalRows()); + + // Set viewport above active by scrolling up one. + s.scroll(.{ .delta_row = -1 }); + // The viewport should be a pin now. + try testing.expectEqual(Viewport.pin, s.viewport); + + // Resize + try s.resize(.{ .rows = 7, .reflow = false }); + try testing.expectEqual(@as(usize, 7), s.rows); + try testing.expectEqual(@as(usize, 7), s.totalRows()); + // The viewport should now be active, not a pin. + try testing.expectEqual(Viewport.active, s.viewport); +} + test "PageList resize (no reflow) less cols" { const testing = std.testing; const alloc = testing.allocator; From 36a3a3ffa42f829629f19934be05b1c97d741c8b Mon Sep 17 00:00:00 2001 From: ClearAspect Date: Thu, 10 Jul 2025 01:48:44 -0400 Subject: [PATCH 85/94] Add tests for getCursorGlyph() helper function --- src/renderer/cell.zig | 3 +++ 1 file changed, 3 insertions(+) diff --git a/src/renderer/cell.zig b/src/renderer/cell.zig index b8b218d99..b1ce4523c 100644 --- a/src/renderer/cell.zig +++ b/src/renderer/cell.zig @@ -361,14 +361,17 @@ test Contents { }; c.setCursor(cursor_cell, .block); try testing.expectEqual(cursor_cell, c.fg_rows.lists[0].items[0]); + try testing.expectEqual(cursor_cell, c.getCursorGlyph().?); // And remove it. c.setCursor(null, null); try testing.expectEqual(0, c.fg_rows.lists[0].items.len); + try testing.expect(c.getCursorGlyph() == null); // Add a hollow cursor. c.setCursor(cursor_cell, .block_hollow); try testing.expectEqual(cursor_cell, c.fg_rows.lists[rows + 1].items[0]); + try testing.expectEqual(cursor_cell, c.getCursorGlyph().?); } test "Contents clear retains other content" { From c23e3f8586964c655744bb8d20d7377210bc4ecb Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Thu, 10 Jul 2025 07:06:53 -0700 Subject: [PATCH 86/94] ci: update sequoia builders to xcode 26 beta 3, output version in CI The `-edge` variant of these builders will always use the latest macOS images that may not be stable. We'll remove this once Xcode 26 is released. --- .github/workflows/release-pr.yml | 4 ++-- .github/workflows/release-tip.yml | 15 ++++++++++++--- .github/workflows/test.yml | 9 ++++++--- 3 files changed, 20 insertions(+), 8 deletions(-) diff --git a/.github/workflows/release-pr.yml b/.github/workflows/release-pr.yml index 7f48d109f..a4bf69b1c 100644 --- a/.github/workflows/release-pr.yml +++ b/.github/workflows/release-pr.yml @@ -47,7 +47,7 @@ jobs: sentry-cli dif upload --project ghostty --wait dsym.zip build-macos: - runs-on: namespace-profile-ghostty-macos-sequoia + runs-on: namespace-profile-ghostty-macos-sequoia-edge timeout-minutes: 90 steps: - name: Checkout code @@ -201,7 +201,7 @@ jobs: destination-dir: ./ build-macos-debug: - runs-on: namespace-profile-ghostty-macos-sequoia + runs-on: namespace-profile-ghostty-macos-sequoia-edge timeout-minutes: 90 steps: - name: Checkout code diff --git a/.github/workflows/release-tip.yml b/.github/workflows/release-tip.yml index 941aed6e5..a9cff9a2b 100644 --- a/.github/workflows/release-tip.yml +++ b/.github/workflows/release-tip.yml @@ -154,7 +154,7 @@ jobs: ) }} - runs-on: namespace-profile-ghostty-macos-sequoia + runs-on: namespace-profile-ghostty-macos-sequoia-edge timeout-minutes: 90 steps: - name: Checkout code @@ -175,6 +175,9 @@ jobs: - name: XCode Select run: sudo xcode-select -s /Applications/Xcode_26.0.app + - name: Xcode Version + run: xcodebuild -version + # Setup Sparkle - name: Setup Sparkle env: @@ -371,7 +374,7 @@ jobs: ) }} - runs-on: namespace-profile-ghostty-macos-sequoia + runs-on: namespace-profile-ghostty-macos-sequoia-edge timeout-minutes: 90 steps: - name: Checkout code @@ -392,6 +395,9 @@ jobs: - name: XCode Select run: sudo xcode-select -s /Applications/Xcode_26.0.app + - name: Xcode Version + run: xcodebuild -version + # Setup Sparkle - name: Setup Sparkle env: @@ -548,7 +554,7 @@ jobs: ) }} - runs-on: namespace-profile-ghostty-macos-sequoia + runs-on: namespace-profile-ghostty-macos-sequoia-edge timeout-minutes: 90 steps: - name: Checkout code @@ -569,6 +575,9 @@ jobs: - name: XCode Select run: sudo xcode-select -s /Applications/Xcode_26.0.app + - name: Xcode Version + run: xcodebuild -version + # Setup Sparkle - name: Setup Sparkle env: diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index cf3d983c0..93ae9734f 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -270,7 +270,7 @@ jobs: ghostty-source.tar.gz build-macos: - runs-on: namespace-profile-ghostty-macos-sequoia + runs-on: namespace-profile-ghostty-macos-sequoia-edge needs: test steps: - name: Checkout code @@ -288,6 +288,9 @@ jobs: - name: Xcode Select run: sudo xcode-select -s /Applications/Xcode_26.0.app + - name: Xcode Version + run: xcodebuild -version + - name: get the Zig deps id: deps run: nix build -L .#deps && echo "deps=$(readlink ./result)" >> $GITHUB_OUTPUT @@ -350,7 +353,7 @@ jobs: xcodebuild -target Ghostty-iOS "CODE_SIGNING_ALLOWED=NO" build-macos-matrix: - runs-on: namespace-profile-ghostty-macos-sequoia + runs-on: namespace-profile-ghostty-macos-sequoia-edge needs: test steps: - name: Checkout code @@ -614,7 +617,7 @@ jobs: nix develop -c zig build -Dsentry=${{ matrix.sentry }} test-macos: - runs-on: namespace-profile-ghostty-macos-sequoia + runs-on: namespace-profile-ghostty-macos-sequoia-edge needs: test steps: - name: Checkout code From 4dc4911ece52da05687ce6d937df71433fc6f635 Mon Sep 17 00:00:00 2001 From: Hojin You Date: Thu, 10 Jul 2025 14:46:46 -0400 Subject: [PATCH 87/94] Update po/ko_KR.UTF-8.po --- po/ko_KR.UTF-8.po | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/po/ko_KR.UTF-8.po b/po/ko_KR.UTF-8.po index 3482453ed..c13fe8a61 100644 --- a/po/ko_KR.UTF-8.po +++ b/po/ko_KR.UTF-8.po @@ -213,7 +213,7 @@ msgstr "이 분할에 대한 선택 기억하기" #: src/apprt/gtk/ui/1.5/ccw-osc-52-read.blp:82 #: src/apprt/gtk/ui/1.5/ccw-osc-52-write.blp:78 msgid "Reload configuration to show this prompt again" -msgstr "이 프롬프트를 다시 표시하려면 구성을 다시 로드하십시오." +msgstr "이 창을 다시 보려면 설정을 다시 불러오세요" #: src/apprt/gtk/ui/1.5/ccw-osc-52-write.blp:7 #: src/apprt/gtk/ui/1.2/ccw-osc-52-write.blp:7 From cbb3f6f64f78c5a572f430096663cd3c8de08c26 Mon Sep 17 00:00:00 2001 From: Bryan Lee <38807139+liby@users.noreply.github.com> Date: Thu, 10 Jul 2025 23:16:23 +0800 Subject: [PATCH 88/94] ci: add shellcheck linting for shell scripts Add shellcheck to CI pipeline to ensure shell scripts follow best practices and catch common errors. Fix existing shellcheck warnings in test scripts to pass the new linting requirements. --- .github/workflows/test.yml | 35 ++++++++++++++++++++++++++++++ nix/build-support/update-mirror.sh | 2 +- nix/devShell.nix | 2 ++ test/run-all.sh | 5 +---- test/run-host.sh | 2 +- test/run.sh | 3 ++- 6 files changed, 42 insertions(+), 7 deletions(-) diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 93ae9734f..86dff2ea1 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -31,6 +31,7 @@ jobs: - prettier - alejandra - typos + - shellcheck - translations - blueprint-compiler - test-pkg-linux @@ -778,6 +779,40 @@ jobs: - name: typos check run: nix develop -c typos + shellcheck: + if: github.repository == 'ghostty-org/ghostty' + runs-on: namespace-profile-ghostty-xsm + timeout-minutes: 60 + env: + ZIG_LOCAL_CACHE_DIR: /zig/local-cache + ZIG_GLOBAL_CACHE_DIR: /zig/global-cache + steps: + - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 + - name: Setup Cache + uses: namespacelabs/nscloud-cache-action@449c929cd5138e6607e7e78458e88cc476e76f89 # v1.2.8 + with: + path: | + /nix + /zig + - uses: cachix/install-nix-action@cebd211ec2008b83bda8fb0b21c3c072f004fe04 # v31.5.0 + with: + nix_path: nixpkgs=channel:nixos-unstable + - uses: cachix/cachix-action@0fc020193b5a1fa3ac4575aa3a7d3aa6a35435ad # v16 + with: + name: ghostty + authToken: "${{ secrets.CACHIX_AUTH_TOKEN }}" + skipPush: true + useDaemon: false # sometimes fails on short jobs + - name: shellcheck + run: | + nix develop -c shellcheck \ + --check-sourced \ + --color=always \ + --severity=warning \ + --shell=bash \ + --external-sources \ + $(find . \( -name "*.sh" -o -name "*.bash" \) -type f ! -path "./zig-out/*" ! -path "./macos/build/*" ! -path "./.git/*" | sort) + translations: if: github.repository == 'ghostty-org/ghostty' runs-on: namespace-profile-ghostty-xsm diff --git a/nix/build-support/update-mirror.sh b/nix/build-support/update-mirror.sh index 35fd841e2..f346572ed 100755 --- a/nix/build-support/update-mirror.sh +++ b/nix/build-support/update-mirror.sh @@ -6,7 +6,7 @@ set -e # Exit immediately if a command exits with a non-zero status -SCRIPT_PATH="$(CDPATH= cd -- "$(dirname -- "$0")" && pwd)" +SCRIPT_PATH="$(CDPATH='' cd -- "$(dirname -- "$0")" && pwd)" INPUT_FILE="$SCRIPT_PATH/../../build.zig.zon2json-lock" OUTPUT_DIR="blob" diff --git a/nix/devShell.nix b/nix/devShell.nix index 8a8ab441f..653c0c0b0 100644 --- a/nix/devShell.nix +++ b/nix/devShell.nix @@ -61,6 +61,7 @@ pinact, hyperfine, typos, + shellcheck, uv, wayland, wayland-scanner, @@ -101,6 +102,7 @@ in alejandra pinact typos + shellcheck # Testing parallel diff --git a/test/run-all.sh b/test/run-all.sh index 77beb344a..d4a785a44 100755 --- a/test/run-all.sh +++ b/test/run-all.sh @@ -9,9 +9,6 @@ DIR=$(cd -- "$( dirname -- "${BASH_SOURCE[0]}" )" &> /dev/null && pwd) # We always copy the bin in case it was rebuilt cp ${DIR}/../zig-out/bin/ghostty ${DIR}/ -# Build our image once -IMAGE=$(docker build --file ${DIR}/Dockerfile -q ${DIR}) - # Unix shortcut to just execute ./run-host for each one. We can do # this less esoterically if we ever wanted. find ${DIR}/cases \ @@ -23,4 +20,4 @@ find ${DIR}/cases \ ${DIR}/run-host.sh \ --case '{}' \ --rewrite-abs-path \ - $@ + "$@" diff --git a/test/run-host.sh b/test/run-host.sh index 887f2cfc1..da9dbe2e5 100755 --- a/test/run-host.sh +++ b/test/run-host.sh @@ -13,4 +13,4 @@ docker run \ --entrypoint "xvfb-run" \ $IMAGE \ --server-args="-screen 0, 1600x900x24" \ - /entrypoint.sh $@ + /entrypoint.sh "$@" diff --git a/test/run.sh b/test/run.sh index 641dc6943..db05ede76 100755 --- a/test/run.sh +++ b/test/run.sh @@ -63,6 +63,7 @@ if [ $bad -ne 0 ]; then fi # Load our test case +# shellcheck disable=SC1090 source ${ARG_CASE} if ! has_func "test_do"; then echo "Test case is invalid." @@ -79,7 +80,7 @@ if [ "$ARG_EXEC" = "ghostty" ]; then # We build in Nix (maybe). To be sure, we replace the interpreter so # it doesn't point to a Nix path. If we don't build in Nix, this should # still be safe. - patchelf --set-interpreter /lib/ld-linux-$(uname -m).so.1 ${ARG_EXEC} + patchelf --set-interpreter /lib/ld-linux-"$(uname -m)".so.1 ${ARG_EXEC} fi #-------------------------------------------------------------------- From 01233a48d14cb9ae191c085a386bccfbafb91243 Mon Sep 17 00:00:00 2001 From: Jon Parise Date: Thu, 10 Jul 2025 15:12:54 -0400 Subject: [PATCH 89/94] bash: preserve an existing ENV value Our bash shell integration code uses ENV (in POSIX mode) to bootstrap our shell integration script. This had the side effect of overwriting an existing ENV value. This change preserves ENV by storing it temporarily in GHOSTTY_BASH_ENV. Note that this doesn't enable --posix mode support for automatic shell integration. (--posix does work; we just skip shell integration when that flag is specified.) We can reconsider implementing full --posix support separately. --- src/shell-integration/bash/ghostty.bash | 6 ++++++ src/termio/shell_integration.zig | 21 +++++++++++++++++++++ 2 files changed, 27 insertions(+) diff --git a/src/shell-integration/bash/ghostty.bash b/src/shell-integration/bash/ghostty.bash index aacf37c3a..ca5a012c6 100644 --- a/src/shell-integration/bash/ghostty.bash +++ b/src/shell-integration/bash/ghostty.bash @@ -26,6 +26,12 @@ if [ -n "$GHOSTTY_BASH_INJECT" ]; then builtin declare __ghostty_bash_flags="$GHOSTTY_BASH_INJECT" builtin unset ENV GHOSTTY_BASH_INJECT + # Restore an existing ENV that was replaced by the shell integration code. + if [[ -n "$GHOSTTY_BASH_ENV" ]]; then + builtin export ENV=$GHOSTTY_BASH_ENV + builtin unset GHOSTTY_BASH_ENV + fi + # Restore bash's default 'posix' behavior. Also reset 'inherit_errexit', # which doesn't happen as part of the 'posix' reset. builtin set +o posix diff --git a/src/termio/shell_integration.zig b/src/termio/shell_integration.zig index 469ff2859..438c2a0ea 100644 --- a/src/termio/shell_integration.zig +++ b/src/termio/shell_integration.zig @@ -340,6 +340,11 @@ fn setupBash( } } + // Preserve an existing ENV value. We're about to overwrite it. + if (env.get("ENV")) |v| { + try env.put("GHOSTTY_BASH_ENV", v); + } + // Set our new ENV to point to our integration script. var path_buf: [std.fs.max_path_bytes]u8 = undefined; const integ_dir = try std.fmt.bufPrint( @@ -502,6 +507,22 @@ test "bash: HISTFILE" { } } +test "bash: ENV" { + const testing = std.testing; + var arena = ArenaAllocator.init(testing.allocator); + defer arena.deinit(); + const alloc = arena.allocator(); + + var env = EnvMap.init(alloc); + defer env.deinit(); + + try env.put("ENV", "env.sh"); + + _ = try setupBash(alloc, .{ .shell = "bash" }, ".", &env); + try testing.expectEqualStrings("./shell-integration/bash/ghostty.bash", env.get("ENV").?); + try testing.expectEqualStrings("env.sh", env.get("GHOSTTY_BASH_ENV").?); +} + test "bash: additional arguments" { const testing = std.testing; var arena = ArenaAllocator.init(testing.allocator); From 099a2d7f03a3062462c96fa7e8d5c03dee3be5b3 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Fri, 11 Jul 2025 00:13:59 +0000 Subject: [PATCH 90/94] build(deps): bump namespacelabs/nscloud-cache-action from 1.2.8 to 1.2.9 Bumps [namespacelabs/nscloud-cache-action](https://github.com/namespacelabs/nscloud-cache-action) from 1.2.8 to 1.2.9. - [Release notes](https://github.com/namespacelabs/nscloud-cache-action/releases) - [Commits](https://github.com/namespacelabs/nscloud-cache-action/compare/449c929cd5138e6607e7e78458e88cc476e76f89...0ac1550c04676e19d39872be6216ccbf9c6bab43) --- updated-dependencies: - dependency-name: namespacelabs/nscloud-cache-action dependency-version: 1.2.9 dependency-type: direct:production update-type: version-update:semver-patch ... Signed-off-by: dependabot[bot] --- .github/workflows/nix.yml | 2 +- .github/workflows/release-tag.yml | 2 +- .github/workflows/release-tip.yml | 2 +- .github/workflows/test.yml | 40 +++++++++++------------ .github/workflows/update-colorschemes.yml | 2 +- 5 files changed, 24 insertions(+), 24 deletions(-) diff --git a/.github/workflows/nix.yml b/.github/workflows/nix.yml index ab881809c..beeaa76a4 100644 --- a/.github/workflows/nix.yml +++ b/.github/workflows/nix.yml @@ -36,7 +36,7 @@ jobs: - name: Checkout code uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 - name: Setup Cache - uses: namespacelabs/nscloud-cache-action@449c929cd5138e6607e7e78458e88cc476e76f89 # v1.2.8 + uses: namespacelabs/nscloud-cache-action@0ac1550c04676e19d39872be6216ccbf9c6bab43 # v1.2.9 with: path: | /nix diff --git a/.github/workflows/release-tag.yml b/.github/workflows/release-tag.yml index ee0b389c1..cc81b1a79 100644 --- a/.github/workflows/release-tag.yml +++ b/.github/workflows/release-tag.yml @@ -83,7 +83,7 @@ jobs: - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 - name: Setup Cache - uses: namespacelabs/nscloud-cache-action@449c929cd5138e6607e7e78458e88cc476e76f89 # v1.2.8 + uses: namespacelabs/nscloud-cache-action@0ac1550c04676e19d39872be6216ccbf9c6bab43 # v1.2.9 with: path: | /nix diff --git a/.github/workflows/release-tip.yml b/.github/workflows/release-tip.yml index a9cff9a2b..efc697f01 100644 --- a/.github/workflows/release-tip.yml +++ b/.github/workflows/release-tip.yml @@ -107,7 +107,7 @@ jobs: steps: - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 - name: Setup Cache - uses: namespacelabs/nscloud-cache-action@449c929cd5138e6607e7e78458e88cc476e76f89 # v1.2.8 + uses: namespacelabs/nscloud-cache-action@0ac1550c04676e19d39872be6216ccbf9c6bab43 # v1.2.9 with: path: | /nix diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 86dff2ea1..e40a9f571 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -69,7 +69,7 @@ jobs: uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 - name: Setup Cache - uses: namespacelabs/nscloud-cache-action@449c929cd5138e6607e7e78458e88cc476e76f89 # v1.2.8 + uses: namespacelabs/nscloud-cache-action@0ac1550c04676e19d39872be6216ccbf9c6bab43 # v1.2.9 with: path: | /nix @@ -100,7 +100,7 @@ jobs: uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 - name: Setup Cache - uses: namespacelabs/nscloud-cache-action@449c929cd5138e6607e7e78458e88cc476e76f89 # v1.2.8 + uses: namespacelabs/nscloud-cache-action@0ac1550c04676e19d39872be6216ccbf9c6bab43 # v1.2.9 with: path: | /nix @@ -136,7 +136,7 @@ jobs: uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 - name: Setup Cache - uses: namespacelabs/nscloud-cache-action@449c929cd5138e6607e7e78458e88cc476e76f89 # v1.2.8 + uses: namespacelabs/nscloud-cache-action@0ac1550c04676e19d39872be6216ccbf9c6bab43 # v1.2.9 with: path: | /nix @@ -165,7 +165,7 @@ jobs: uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 - name: Setup Cache - uses: namespacelabs/nscloud-cache-action@449c929cd5138e6607e7e78458e88cc476e76f89 # v1.2.8 + uses: namespacelabs/nscloud-cache-action@0ac1550c04676e19d39872be6216ccbf9c6bab43 # v1.2.9 with: path: | /nix @@ -198,7 +198,7 @@ jobs: uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 - name: Setup Cache - uses: namespacelabs/nscloud-cache-action@449c929cd5138e6607e7e78458e88cc476e76f89 # v1.2.8 + uses: namespacelabs/nscloud-cache-action@0ac1550c04676e19d39872be6216ccbf9c6bab43 # v1.2.9 with: path: | /nix @@ -242,7 +242,7 @@ jobs: uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 - name: Setup Cache - uses: namespacelabs/nscloud-cache-action@449c929cd5138e6607e7e78458e88cc476e76f89 # v1.2.8 + uses: namespacelabs/nscloud-cache-action@0ac1550c04676e19d39872be6216ccbf9c6bab43 # v1.2.9 with: path: | /nix @@ -413,7 +413,7 @@ jobs: mkdir dist tar --verbose --extract --strip-components 1 --directory dist --file ghostty-source.tar.gz - name: Setup Cache - uses: namespacelabs/nscloud-cache-action@449c929cd5138e6607e7e78458e88cc476e76f89 # v1.2.8 + uses: namespacelabs/nscloud-cache-action@0ac1550c04676e19d39872be6216ccbf9c6bab43 # v1.2.9 with: path: | /nix @@ -508,7 +508,7 @@ jobs: uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 - name: Setup Cache - uses: namespacelabs/nscloud-cache-action@449c929cd5138e6607e7e78458e88cc476e76f89 # v1.2.8 + uses: namespacelabs/nscloud-cache-action@0ac1550c04676e19d39872be6216ccbf9c6bab43 # v1.2.9 with: path: | /nix @@ -550,7 +550,7 @@ jobs: uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 - name: Setup Cache - uses: namespacelabs/nscloud-cache-action@449c929cd5138e6607e7e78458e88cc476e76f89 # v1.2.8 + uses: namespacelabs/nscloud-cache-action@0ac1550c04676e19d39872be6216ccbf9c6bab43 # v1.2.9 with: path: | /nix @@ -598,7 +598,7 @@ jobs: uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 - name: Setup Cache - uses: namespacelabs/nscloud-cache-action@449c929cd5138e6607e7e78458e88cc476e76f89 # v1.2.8 + uses: namespacelabs/nscloud-cache-action@0ac1550c04676e19d39872be6216ccbf9c6bab43 # v1.2.9 with: path: | /nix @@ -653,7 +653,7 @@ jobs: steps: - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 - name: Setup Cache - uses: namespacelabs/nscloud-cache-action@449c929cd5138e6607e7e78458e88cc476e76f89 # v1.2.8 + uses: namespacelabs/nscloud-cache-action@0ac1550c04676e19d39872be6216ccbf9c6bab43 # v1.2.9 with: path: | /nix @@ -681,7 +681,7 @@ jobs: steps: - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 - name: Setup Cache - uses: namespacelabs/nscloud-cache-action@449c929cd5138e6607e7e78458e88cc476e76f89 # v1.2.8 + uses: namespacelabs/nscloud-cache-action@0ac1550c04676e19d39872be6216ccbf9c6bab43 # v1.2.9 with: path: | /nix @@ -708,7 +708,7 @@ jobs: steps: - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 - name: Setup Cache - uses: namespacelabs/nscloud-cache-action@449c929cd5138e6607e7e78458e88cc476e76f89 # v1.2.8 + uses: namespacelabs/nscloud-cache-action@0ac1550c04676e19d39872be6216ccbf9c6bab43 # v1.2.9 with: path: | /nix @@ -735,7 +735,7 @@ jobs: steps: - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 - name: Setup Cache - uses: namespacelabs/nscloud-cache-action@449c929cd5138e6607e7e78458e88cc476e76f89 # v1.2.8 + uses: namespacelabs/nscloud-cache-action@0ac1550c04676e19d39872be6216ccbf9c6bab43 # v1.2.9 with: path: | /nix @@ -762,7 +762,7 @@ jobs: steps: - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 - name: Setup Cache - uses: namespacelabs/nscloud-cache-action@449c929cd5138e6607e7e78458e88cc476e76f89 # v1.2.8 + uses: namespacelabs/nscloud-cache-action@0ac1550c04676e19d39872be6216ccbf9c6bab43 # v1.2.9 with: path: | /nix @@ -789,7 +789,7 @@ jobs: steps: - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 - name: Setup Cache - uses: namespacelabs/nscloud-cache-action@449c929cd5138e6607e7e78458e88cc476e76f89 # v1.2.8 + uses: namespacelabs/nscloud-cache-action@0ac1550c04676e19d39872be6216ccbf9c6bab43 # v1.2.9 with: path: | /nix @@ -823,7 +823,7 @@ jobs: steps: - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 - name: Setup Cache - uses: namespacelabs/nscloud-cache-action@449c929cd5138e6607e7e78458e88cc476e76f89 # v1.2.8 + uses: namespacelabs/nscloud-cache-action@0ac1550c04676e19d39872be6216ccbf9c6bab43 # v1.2.9 with: path: | /nix @@ -850,7 +850,7 @@ jobs: steps: - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 - name: Setup Cache - uses: namespacelabs/nscloud-cache-action@449c929cd5138e6607e7e78458e88cc476e76f89 # v1.2.8 + uses: namespacelabs/nscloud-cache-action@0ac1550c04676e19d39872be6216ccbf9c6bab43 # v1.2.9 with: path: | /nix @@ -885,7 +885,7 @@ jobs: uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 - name: Setup Cache - uses: namespacelabs/nscloud-cache-action@449c929cd5138e6607e7e78458e88cc476e76f89 # v1.2.8 + uses: namespacelabs/nscloud-cache-action@0ac1550c04676e19d39872be6216ccbf9c6bab43 # v1.2.9 with: path: | /nix @@ -943,7 +943,7 @@ jobs: - name: Checkout code uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 - name: Setup Cache - uses: namespacelabs/nscloud-cache-action@449c929cd5138e6607e7e78458e88cc476e76f89 # v1.2.8 + uses: namespacelabs/nscloud-cache-action@0ac1550c04676e19d39872be6216ccbf9c6bab43 # v1.2.9 with: path: | /nix diff --git a/.github/workflows/update-colorschemes.yml b/.github/workflows/update-colorschemes.yml index 5392a9e11..20cda12c9 100644 --- a/.github/workflows/update-colorschemes.yml +++ b/.github/workflows/update-colorschemes.yml @@ -22,7 +22,7 @@ jobs: fetch-depth: 0 - name: Setup Cache - uses: namespacelabs/nscloud-cache-action@449c929cd5138e6607e7e78458e88cc476e76f89 # v1.2.8 + uses: namespacelabs/nscloud-cache-action@0ac1550c04676e19d39872be6216ccbf9c6bab43 # v1.2.9 with: path: | /nix From 9fa26387ef3dc4311594a87b7640afe75c5a3d11 Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Thu, 10 Jul 2025 07:10:50 -0700 Subject: [PATCH 91/94] build: `zig build test` runs Xcode tests on macOS Related to #7879 This commit updates `zig build test` to run Xcode tests, too. These run in parallel to the Zig tests, so they don't add any time to the test. The Xcode tests will _not_ run when: (1) the target is not macOS, or (2) the `-Dtest-filter` option is non-empty. This makes it so that this change doesn't affect non-macOS and doesn't affect the general dev cycle because you usually will run `-Dtest-filter` when developing a core feature. I didn't add a step to only run Xcode tests because I find that when I'm working in Xcode I'm probably going to run the tests from there anyways. The integration with `zig build test` is just a convenience, especially around CI. Speaking of CI, this change also makes it so this will run in CI. --- build.zig | 41 +++++--- macos/Ghostty.xcodeproj/project.pbxproj | 6 +- .../BenchmarkTests.swift | 0 pkg/harfbuzz/build.zig | 26 +++-- pkg/harfbuzz/c.zig | 5 +- src/build/GhosttyXcodebuild.zig | 98 ++++++++++++++----- src/build/SharedDeps.zig | 2 +- 7 files changed, 121 insertions(+), 57 deletions(-) rename macos/{GhosttyTests => Tests}/BenchmarkTests.swift (100%) diff --git a/build.zig b/build.zig index 024e2db61..1c98b2fa5 100644 --- a/build.zig +++ b/build.zig @@ -8,7 +8,22 @@ comptime { } pub fn build(b: *std.Build) !void { + // This defines all the available build options (e.g. `-D`). const config = try buildpkg.Config.init(b); + const test_filter = b.option( + []const u8, + "test-filter", + "Filter for test. Only applies to Zig tests.", + ); + + // All our steps which we'll hook up later. The steps are shown + // up here just so that they are more self-documenting. + const run_step = b.step("run", "Run the app"); + const test_step = b.step("test", "Run all tests"); + const translations_step = b.step( + "update-translations", + "Update translation files", + ); // Ghostty resources like terminfo, shell integration, themes, etc. const resources = try buildpkg.GhosttyResources.init(b, &config); @@ -131,7 +146,6 @@ pub fn build(b: *std.Build) !void { b.getInstallPath(.prefix, "share/ghostty"), ); - const run_step = b.step("run", "Run the app"); run_step.dependOn(&run_cmd.step); break :run; } @@ -157,16 +171,18 @@ pub fn build(b: *std.Build) !void { }, ); - const run_step = b.step("run", "Run the app"); + // Run uses the native macOS app run_step.dependOn(&macos_app_native_only.open.step); + + // If we have no test filters, install the tests too + if (test_filter == null) { + macos_app_native_only.addTestStepDependencies(test_step); + } } } // Tests { - const test_step = b.step("test", "Run all tests"); - const test_filter = b.option([]const u8, "test-filter", "Filter for test"); - const test_exe = b.addTest(.{ .name = "ghostty-test", .filters = if (test_filter) |v| &.{v} else &.{}, @@ -180,18 +196,13 @@ pub fn build(b: *std.Build) !void { }), }); - { - if (config.emit_test_exe) b.installArtifact(test_exe); - _ = try deps.add(test_exe); - const test_run = b.addRunArtifact(test_exe); - test_step.dependOn(&test_run.step); - } + if (config.emit_test_exe) b.installArtifact(test_exe); + _ = try deps.add(test_exe); + const test_run = b.addRunArtifact(test_exe); + test_step.dependOn(&test_run.step); } // update-translations does what it sounds like and updates the "pot" // files. These should be committed to the repo. - { - const step = b.step("update-translations", "Update translation files"); - step.dependOn(i18n.update_step); - } + translations_step.dependOn(i18n.update_step); } diff --git a/macos/Ghostty.xcodeproj/project.pbxproj b/macos/Ghostty.xcodeproj/project.pbxproj index f7ae5f525..0c54ba693 100644 --- a/macos/Ghostty.xcodeproj/project.pbxproj +++ b/macos/Ghostty.xcodeproj/project.pbxproj @@ -303,7 +303,7 @@ /* End PBXFileReference section */ /* Begin PBXFileSystemSynchronizedRootGroup section */ - A54F45F42E1F047A0046BD5C /* GhosttyTests */ = {isa = PBXFileSystemSynchronizedRootGroup; explicitFileTypes = {}; explicitFolders = (); path = GhosttyTests; sourceTree = ""; }; + A54F45F42E1F047A0046BD5C /* Tests */ = {isa = PBXFileSystemSynchronizedRootGroup; explicitFileTypes = {}; explicitFolders = (); path = Tests; sourceTree = ""; }; /* End PBXFileSystemSynchronizedRootGroup section */ /* Begin PBXFrameworksBuildPhase section */ @@ -612,7 +612,7 @@ A51BFC282B30F26D00E92F16 /* GhosttyDebug.entitlements */, 3B39CAA42B33949B00DABEB8 /* GhosttyReleaseLocal.entitlements */, A54CD6ED299BEB14008C95BB /* Sources */, - A54F45F42E1F047A0046BD5C /* GhosttyTests */, + A54F45F42E1F047A0046BD5C /* Tests */, A5D495A3299BECBA00DD1313 /* Frameworks */, A5A1F8862A489D7400D1E8BC /* Resources */, A5B30532299BEAAA0047F10C /* Products */, @@ -712,7 +712,7 @@ A54F45F82E1F047A0046BD5C /* PBXTargetDependency */, ); fileSystemSynchronizedGroups = ( - A54F45F42E1F047A0046BD5C /* GhosttyTests */, + A54F45F42E1F047A0046BD5C /* Tests */, ); name = GhosttyTests; packageProductDependencies = ( diff --git a/macos/GhosttyTests/BenchmarkTests.swift b/macos/Tests/BenchmarkTests.swift similarity index 100% rename from macos/GhosttyTests/BenchmarkTests.swift rename to macos/Tests/BenchmarkTests.swift diff --git a/pkg/harfbuzz/build.zig b/pkg/harfbuzz/build.zig index 3bdc30a32..424f2afed 100644 --- a/pkg/harfbuzz/build.zig +++ b/pkg/harfbuzz/build.zig @@ -15,15 +15,23 @@ pub fn build(b: *std.Build) !void { }); const macos = b.dependency("macos", .{ .target = target, .optimize = optimize }); - const module = b.addModule("harfbuzz", .{ - .root_source_file = b.path("main.zig"), - .target = target, - .optimize = optimize, - .imports = &.{ - .{ .name = "freetype", .module = freetype.module("freetype") }, - .{ .name = "macos", .module = macos.module("macos") }, - }, - }); + const module = harfbuzz: { + const module = b.addModule("harfbuzz", .{ + .root_source_file = b.path("main.zig"), + .target = target, + .optimize = optimize, + .imports = &.{ + .{ .name = "freetype", .module = freetype.module("freetype") }, + .{ .name = "macos", .module = macos.module("macos") }, + }, + }); + + const options = b.addOptions(); + options.addOption(bool, "coretext", coretext_enabled); + options.addOption(bool, "freetype", freetype_enabled); + module.addOptions("build_options", options); + break :harfbuzz module; + }; // For dynamic linking, we prefer dynamic linking and to search by // mode first. Mode first will search all paths for a dynamic library diff --git a/pkg/harfbuzz/c.zig b/pkg/harfbuzz/c.zig index 51e477ebf..49e87dce7 100644 --- a/pkg/harfbuzz/c.zig +++ b/pkg/harfbuzz/c.zig @@ -1,7 +1,8 @@ const builtin = @import("builtin"); +const build_options = @import("build_options"); pub const c = @cImport({ @cInclude("hb.h"); - @cInclude("hb-ft.h"); - if (builtin.os.tag == .macos) @cInclude("hb-coretext.h"); + if (build_options.freetype) @cInclude("hb-ft.h"); + if (build_options.coretext) @cInclude("hb-coretext.h"); }); diff --git a/src/build/GhosttyXcodebuild.zig b/src/build/GhosttyXcodebuild.zig index 7fa2d2f95..d3bda032d 100644 --- a/src/build/GhosttyXcodebuild.zig +++ b/src/build/GhosttyXcodebuild.zig @@ -12,6 +12,7 @@ const XCFramework = @import("GhosttyXCFramework.zig"); build: *std.Build.Step.Run, open: *std.Build.Step.Run, copy: *std.Build.Step.Run, +xctest: *std.Build.Step.Run, pub const Deps = struct { xcframework: *const XCFramework, @@ -33,6 +34,21 @@ pub fn init( => "Release", }; + const xc_arch: ?[]const u8 = switch (deps.xcframework.target) { + // Universal is our default target, so we don't have to + // add anything. + .universal => null, + + // Native we need to override the architecture in the Xcode + // project with the -arch flag. + .native => switch (builtin.cpu.arch) { + .aarch64 => "arm64", + .x86_64 => "x86_64", + else => @panic("unsupported macOS arch"), + }, + }; + + const env = try std.process.getEnvMap(b.allocator); const app_path = b.fmt("macos/build/{s}/Ghostty.app", .{xc_config}); // Our step to build the Ghostty macOS app. @@ -41,12 +57,13 @@ pub fn init( // we create a new empty environment. const env_map = try b.allocator.create(std.process.EnvMap); env_map.* = .init(b.allocator); + if (env.get("PATH")) |v| try env_map.put("PATH", v); - const build = RunStep.create(b, "xcodebuild"); - build.has_side_effects = true; - build.cwd = b.path("macos"); - build.env_map = env_map; - build.addArgs(&.{ + const step = RunStep.create(b, "xcodebuild"); + step.has_side_effects = true; + step.cwd = b.path("macos"); + step.env_map = env_map; + step.addArgs(&.{ "xcodebuild", "-target", "Ghostty", @@ -54,36 +71,55 @@ pub fn init( xc_config, }); - switch (deps.xcframework.target) { - // Universal is our default target, so we don't have to - // add anything. - .universal => {}, - - // Native we need to override the architecture in the Xcode - // project with the -arch flag. - .native => build.addArgs(&.{ - "-arch", - switch (builtin.cpu.arch) { - .aarch64 => "arm64", - .x86_64 => "x86_64", - else => @panic("unsupported macOS arch"), - }, - }), - } + // If we have a specific architecture, we need to pass it + // to xcodebuild. + if (xc_arch) |arch| step.addArgs(&.{ "-arch", arch }); // We need the xcframework - deps.xcframework.addStepDependencies(&build.step); + deps.xcframework.addStepDependencies(&step.step); // We also need all these resources because the xcode project // references them via symlinks. - deps.resources.addStepDependencies(&build.step); - deps.i18n.addStepDependencies(&build.step); - deps.docs.installDummy(&build.step); + deps.resources.addStepDependencies(&step.step); + deps.i18n.addStepDependencies(&step.step); + deps.docs.installDummy(&step.step); // Expect success - build.expectExitCode(0); + step.expectExitCode(0); - break :build build; + break :build step; + }; + + const xctest = xctest: { + const env_map = try b.allocator.create(std.process.EnvMap); + env_map.* = .init(b.allocator); + if (env.get("PATH")) |v| try env_map.put("PATH", v); + + const step = RunStep.create(b, "xcodebuild test"); + step.has_side_effects = true; + step.cwd = b.path("macos"); + step.env_map = env_map; + step.addArgs(&.{ + "xcodebuild", + "test", + "-scheme", + "Ghostty", + }); + if (xc_arch) |arch| step.addArgs(&.{ "-arch", arch }); + + // We need the xcframework + deps.xcframework.addStepDependencies(&step.step); + + // We also need all these resources because the xcode project + // references them via symlinks. + deps.resources.addStepDependencies(&step.step); + deps.i18n.addStepDependencies(&step.step); + deps.docs.installDummy(&step.step); + + // Expect success + step.expectExitCode(0); + + break :xctest step; }; // Our step to open the resulting Ghostty app. @@ -143,6 +179,7 @@ pub fn init( .build = build, .open = open, .copy = copy, + .xctest = xctest, }; } @@ -155,3 +192,10 @@ pub fn installXcframework(self: *const Ghostty) void { const b = self.build.step.owner; b.getInstallStep().dependOn(&self.build.step); } + +pub fn addTestStepDependencies( + self: *const Ghostty, + other_step: *std.Build.Step, +) void { + other_step.dependOn(&self.xctest.step); +} diff --git a/src/build/SharedDeps.zig b/src/build/SharedDeps.zig index b6e9900e2..ea7e696ef 100644 --- a/src/build/SharedDeps.zig +++ b/src/build/SharedDeps.zig @@ -139,7 +139,7 @@ pub fn add( if (b.lazyDependency("harfbuzz", .{ .target = target, .optimize = optimize, - .@"enable-freetype" = true, + .@"enable-freetype" = self.config.font_backend.hasFreetype(), .@"enable-coretext" = self.config.font_backend.hasCoretext(), })) |harfbuzz_dep| { step.root_module.addImport( From cfad2e817b1f4992715a80c915e08402e52d2f45 Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Thu, 10 Jul 2025 21:11:55 -0700 Subject: [PATCH 92/94] ci: downgrade back to Xcode 26 beta 1, the icon is broken in beta 3 --- .github/workflows/release-pr.yml | 4 ++-- .github/workflows/release-tip.yml | 6 +++--- .github/workflows/test.yml | 6 +++--- 3 files changed, 8 insertions(+), 8 deletions(-) diff --git a/.github/workflows/release-pr.yml b/.github/workflows/release-pr.yml index a4bf69b1c..7f48d109f 100644 --- a/.github/workflows/release-pr.yml +++ b/.github/workflows/release-pr.yml @@ -47,7 +47,7 @@ jobs: sentry-cli dif upload --project ghostty --wait dsym.zip build-macos: - runs-on: namespace-profile-ghostty-macos-sequoia-edge + runs-on: namespace-profile-ghostty-macos-sequoia timeout-minutes: 90 steps: - name: Checkout code @@ -201,7 +201,7 @@ jobs: destination-dir: ./ build-macos-debug: - runs-on: namespace-profile-ghostty-macos-sequoia-edge + runs-on: namespace-profile-ghostty-macos-sequoia timeout-minutes: 90 steps: - name: Checkout code diff --git a/.github/workflows/release-tip.yml b/.github/workflows/release-tip.yml index a9cff9a2b..d784301b5 100644 --- a/.github/workflows/release-tip.yml +++ b/.github/workflows/release-tip.yml @@ -154,7 +154,7 @@ jobs: ) }} - runs-on: namespace-profile-ghostty-macos-sequoia-edge + runs-on: namespace-profile-ghostty-macos-sequoia timeout-minutes: 90 steps: - name: Checkout code @@ -374,7 +374,7 @@ jobs: ) }} - runs-on: namespace-profile-ghostty-macos-sequoia-edge + runs-on: namespace-profile-ghostty-macos-sequoia timeout-minutes: 90 steps: - name: Checkout code @@ -554,7 +554,7 @@ jobs: ) }} - runs-on: namespace-profile-ghostty-macos-sequoia-edge + runs-on: namespace-profile-ghostty-macos-sequoia timeout-minutes: 90 steps: - name: Checkout code diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 86dff2ea1..7e2b4e68b 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -271,7 +271,7 @@ jobs: ghostty-source.tar.gz build-macos: - runs-on: namespace-profile-ghostty-macos-sequoia-edge + runs-on: namespace-profile-ghostty-macos-sequoia needs: test steps: - name: Checkout code @@ -354,7 +354,7 @@ jobs: xcodebuild -target Ghostty-iOS "CODE_SIGNING_ALLOWED=NO" build-macos-matrix: - runs-on: namespace-profile-ghostty-macos-sequoia-edge + runs-on: namespace-profile-ghostty-macos-sequoia needs: test steps: - name: Checkout code @@ -618,7 +618,7 @@ jobs: nix develop -c zig build -Dsentry=${{ matrix.sentry }} test-macos: - runs-on: namespace-profile-ghostty-macos-sequoia-edge + runs-on: namespace-profile-ghostty-macos-sequoia needs: test steps: - name: Checkout code From 991426e84e069008e36d6fddeef52dbc697df04f Mon Sep 17 00:00:00 2001 From: nferhat Date: Fri, 11 Jul 2025 12:47:28 +0100 Subject: [PATCH 93/94] renderer: Allow the renderer to draw transparent cells Co-authored-by: Kat <65649991+00-kat@users.noreply.github.com> --- src/config/Config.zig | 16 ++++++++++++++++ src/renderer/generic.zig | 9 +++++++++ 2 files changed, 25 insertions(+) diff --git a/src/config/Config.zig b/src/config/Config.zig index 5ebb5561b..1e2086876 100644 --- a/src/config/Config.zig +++ b/src/config/Config.zig @@ -814,6 +814,22 @@ palette: Palette = .{}, /// On macOS, changing this configuration requires restarting Ghostty completely. @"background-opacity": f64 = 1.0, +/// Applies background opacity to cells with an explicit background color +/// set. +/// +/// Normally, `background-opacity` is only applied to the window background. +/// If a cell has an explicit background color set, such as red, then that +/// background color will be fully opaque. An effect of this is that some +/// terminal applications that repaint the background color of the terminal +/// such as a Neovim and Tmux may not respect the `background-opacity` +/// (by design). +/// +/// Setting this to `true` will apply the `background-opacity` to all cells +/// regardless of whether they have an explicit background color set or not. +/// +/// Available since: 1.2.0 +@"background-opacity-cells": bool = false, + /// Whether to blur the background when `background-opacity` is less than 1. /// /// Valid values are: diff --git a/src/renderer/generic.zig b/src/renderer/generic.zig index 2374ec1b0..1517ec662 100644 --- a/src/renderer/generic.zig +++ b/src/renderer/generic.zig @@ -516,6 +516,7 @@ pub fn Renderer(comptime GraphicsAPI: type) type { cursor_text: ?configpkg.Config.TerminalColor, background: terminal.color.RGB, background_opacity: f64, + background_opacity_cells: bool, foreground: terminal.color.RGB, selection_background: ?configpkg.Config.TerminalColor, selection_foreground: ?configpkg.Config.TerminalColor, @@ -568,6 +569,7 @@ pub fn Renderer(comptime GraphicsAPI: type) type { return .{ .background_opacity = @max(0, @min(1, config.@"background-opacity")), + .background_opacity_cells = config.@"background-opacity-cells", .font_thicken = config.@"font-thicken", .font_thicken_strength = config.@"font-thicken-strength", .font_features = font_features.list, @@ -2628,6 +2630,13 @@ pub fn Renderer(comptime GraphicsAPI: type) type { // Cells that are reversed should be fully opaque. if (style.flags.inverse) break :bg_alpha default; + // If the user requested to have opacity on all cells, apply it. + if (self.config.background_opacity_cells and bg_style != null) { + var opacity: f64 = @floatFromInt(default); + opacity *= self.config.background_opacity; + break :bg_alpha @intFromFloat(opacity); + } + // Cells that have an explicit bg color should be fully opaque. if (bg_style != null) break :bg_alpha default; From c4e10a1ac1f808c53d036e273973fd717ffdbd09 Mon Sep 17 00:00:00 2001 From: Hojin You Date: Fri, 11 Jul 2025 10:06:34 -0400 Subject: [PATCH 94/94] Update po/ko_KR.UTF-8.po remove extra space Co-authored-by: trag1c --- po/ko_KR.UTF-8.po | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/po/ko_KR.UTF-8.po b/po/ko_KR.UTF-8.po index c13fe8a61..875e7a1a5 100644 --- a/po/ko_KR.UTF-8.po +++ b/po/ko_KR.UTF-8.po @@ -213,7 +213,7 @@ msgstr "이 분할에 대한 선택 기억하기" #: src/apprt/gtk/ui/1.5/ccw-osc-52-read.blp:82 #: src/apprt/gtk/ui/1.5/ccw-osc-52-write.blp:78 msgid "Reload configuration to show this prompt again" -msgstr "이 창을 다시 보려면 설정을 다시 불러오세요" +msgstr "이 창을 다시 보려면 설정을 다시 불러오세요" #: src/apprt/gtk/ui/1.5/ccw-osc-52-write.blp:7 #: src/apprt/gtk/ui/1.2/ccw-osc-52-write.blp:7