Skip to content

Add SSH Integration Configuration Option #7608

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Open
wants to merge 40 commits into
base: main
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
40 commits
Select commit Hold shift + click to select a range
142e07c
feat: add SSH integration wrapper for shell integration
jasonrayne Jun 13, 2025
8f93d8f
fix: use kebab-case for ssh-integration enum values
jasonrayne Jun 14, 2025
34af3ff
docs: inline ssh-integration documentation instead of referencing enum
jasonrayne Jun 14, 2025
2babdb4
refactor: simplify ssh integration environment variable checks
jasonrayne Jun 16, 2025
842ced9
bash: preserve mixed indentation in SSH integration changes
jasonrayne Jun 16, 2025
c706434
bash: revert all formatting changes
jasonrayne Jun 16, 2025
b07b3e4
fish: revert all formatting changes
jasonrayne Jun 16, 2025
2e9a0e9
fix: clean up SSH environment variable propagation
jasonrayne Jun 16, 2025
050cb3b
fix: remove unnecessary jsonStringify method
jasonrayne Jun 16, 2025
4206ab1
fix: use idiomatic Fish shell syntax in SSH integration
jasonrayne Jun 16, 2025
8fafd5a
docs: expand SSH integration configuration documentation
jasonrayne Jun 16, 2025
af28763
fix: trailing newline in Config.zig
jasonrayne Jun 17, 2025
80475e1
fix: critical elvish syntax errors for environment variables
jasonrayne Jun 17, 2025
fb8f6c7
fix: remove dangling resources_dir var
jasonrayne Jun 17, 2025
3319b2b
docs: added full stop for consistency
jasonrayne Jun 17, 2025
2ddcf2f
fix: remove resources_dir var, add builtin prefix for consistency
jasonrayne Jun 17, 2025
995fb09
fix: add builtin prefix for safety and consistency
jasonrayne Jun 17, 2025
b6bb9ab
fix: address comprehensive shell integration code review issues
jasonrayne Jun 17, 2025
4cebee5
fix: add client-side caching to eliminate redundant terminfo installa…
jasonrayne Jun 18, 2025
69f9976
fix: manual formatting pass to ensure consistency with existing patterns
jasonrayne Jun 18, 2025
f206e76
ssh-integration: improve host caching, new method for "full" integration
jasonrayne Jun 18, 2025
3068397
fix: catch up to current state
jasonrayne Jun 18, 2025
ddd3da4
fix: update cache file location
jasonrayne Jun 18, 2025
e73313e
change: migrate SSH integration from standalone option to shell-integ…
jasonrayne Jun 24, 2025
81641e5
ssh-integration: replace levels with flags, optimize implementation
jasonrayne Jun 24, 2025
c8d5e60
docs: expand flag descriptions, usage overview
jasonrayne Jun 24, 2025
bbb02a8
test: update shell integration tests for SSH flags
jasonrayne Jun 24, 2025
8a2fa64
refactor: extract SSH cache functionality to shared script
jasonrayne Jun 25, 2025
6789b7f
docs: add shared directory section to shell-integration README
jasonrayne Jun 25, 2025
0565ed3
refactor: replace ghostty wrapper with proper CLI actions for terminf…
jasonrayne Jun 25, 2025
f617c9b
docs: update ssh-terminfo description to reference new CLI actions
jasonrayne Jun 25, 2025
0ccb7cf
docs: improve SSH cache CLI action descriptions
jasonrayne Jun 25, 2025
21d95c4
docs: improve clear-ssh-cache description (missed in previous commit)
jasonrayne Jun 25, 2025
931efcd
fix: restore background-image config accidentally removed during rebase
jasonrayne Jun 25, 2025
59229d7
style: revert fish_indent quote removal
jasonrayne Jun 25, 2025
e5e2a56
fix: use imported modules consistently in action dispatch
jasonrayne Jun 25, 2025
1873add
docs: call out bash dependency
jasonrayne Jun 26, 2025
eed2006
fix: correct resources directory fallback path and eliminate code dup…
jasonrayne Jun 26, 2025
b537246
docs: clarify infocmp/tic requirements for ssh-terminfo feature
jasonrayne Jun 26, 2025
076f742
fix: replace non-existent GHOSTTY_VERSION with TERM_PROGRAM_VERSION i…
jasonrayne Jun 26, 2025
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
12 changes: 12 additions & 0 deletions src/cli/action.zig
Original file line number Diff line number Diff line change
Expand Up @@ -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");
Expand Down Expand Up @@ -41,6 +43,12 @@ 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",

/// Edit the config file in the configured terminal editor.
@"edit-config",

Expand Down Expand Up @@ -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" => 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),
Expand Down Expand Up @@ -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,
Expand Down
40 changes: 40 additions & 0 deletions src/cli/clear_ssh_cache.zig
Original file line number Diff line number Diff line change
@@ -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;
}
41 changes: 41 additions & 0 deletions src/cli/list_ssh_cache.zig
Original file line number Diff line number Diff line change
@@ -0,0 +1,41 @@
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;
}
51 changes: 51 additions & 0 deletions src/cli/ssh_cache.zig
Original file line number Diff line number Diff line change
@@ -0,0 +1,51 @@
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");
};
defer alloc.free(resources_dir);

return try std.fs.path.join(alloc, &[_][]const u8{ resources_dir, "shell-integration", "shared", "ghostty-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);

var child = Child.init(&[_][]const u8{ script_path, command }, 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);
}
}

/// 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 {
try runCacheCommand(alloc, writer, "clear");
}
22 changes: 21 additions & 1 deletion src/config/Config.zig
Original file line number Diff line number Diff line change
Expand Up @@ -2051,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.
Expand All @@ -2059,7 +2061,23 @@ keybind: Keybinds = .{},
///
/// * `title` - Set the window title via shell integration.
///
/// Example: `cursor`, `no-cursor`, `sudo`, `no-sudo`, `title`, `no-title`
/// * `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. 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
Comment on lines +2073 to +2074
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

In the current implementation, these commands are always available (ssh-terminfo doesn't enable/disable them). Maybe reword this a bit to reflect that?

/// 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
/// terminfo on remote hosts and use `xterm-ghostty` as TERM, falling back to
/// `xterm-256color` with environment variables if terminfo installation fails.
@"shell-integration-features": ShellIntegrationFeatures = .{},

/// Custom entries into the command palette.
Expand Down Expand Up @@ -6233,6 +6251,8 @@ pub const ShellIntegrationFeatures = packed struct {
cursor: bool = true,
sudo: bool = false,
title: bool = true,
@"ssh-env": bool = false,
@"ssh-terminfo": bool = false,
};

pub const RepeatableCommand = struct {
Expand Down
20 changes: 20 additions & 0 deletions src/shell-integration/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -88,3 +88,23 @@ 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.
72 changes: 72 additions & 0 deletions src/shell-integration/bash/ghostty.bash
Original file line number Diff line number Diff line change
Expand Up @@ -95,6 +95,78 @@ if [[ "$GHOSTTY_SHELL_FEATURES" == *"sudo"* && -n "$TERMINFO" ]]; then
}
fi

# SSH Integration
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"
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This reintroduces a minor dependency on $GHOSTTY_RESOURCES_DIR (see #7611). I think that's fine because it's only used in this inner path, but just noting it.

fi

# SSH wrapper
ssh() {
local env=() opts=() ctrl=()
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

It's not a big thing either way, but it might be helpful to prefix these particular local variables with ssh_ to communicate that these are the values we're going to use when executing ssh itself.

Suggested change
local env=() opts=() ctrl=()
local ssh_env=() ssh_opts=() ssh_ctrl=()


# Set up env vars first so terminfo installation inherits them
if [[ "$GHOSTTY_SHELL_FEATURES" =~ ssh-env ]]; then
local vars=(
COLORTERM=truecolor
TERM_PROGRAM=ghostty
${TERM_PROGRAM_VERSION:+TERM_PROGRAM_VERSION=$TERM_PROGRAM_VERSION}
)
for v in "${vars[@]}"; do
builtin export "${v?}"
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This export will alter the local environment as a side-effect of executing this ssh function. Do we need to clean these up after ssh completes?

Could also take the same env-based approach we use for the ssh commnd itself to avoid changing the local environent there?

opts+=(-o "SendEnv ${v%=*}" -o "SetEnv $v")
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

My (limited) understanding is that SendEnv is for sending local values to the server and SetEnv is for injecting specific values into the server-side session. I'm not sure I follow why we need to do both for the same set of $vars though. Could you help me understand that?

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 target
target=$(builtin command ssh -G "$@" 2>/dev/null | awk '/^(user|hostname) /{print $2}' | paste -sd'@')
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This command pipeline doesn't appear to work for me (on macOS):

usage: paste [-s] [-d delimiters] file ...

I think it's attempting to form the string {user}@{hostname}. We can also look at other ways to do that which don't involve a dependency on awk and/or paste.

Copy link
Member

@pluiedev pluiedev Jun 26, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yeah this is partly why we should avoid writing things in Bash. The behavior differences between GNU/BSD awk/grep/sed/etc. are just too great. I think this is perfectly doable in Zig and this kind of thing would be avoided. (Didn't realize that this is the shell integration and not the ssh cache scripts)

In this case specifically GNU paste says:

With no FILE, or when FILE is -, read standard input.

And I presume BSD paste requires specifying - or /dev/stdin for this to work.

EDIT: Yup:

If “-” is specified for one or more of the input files, the standard input is used; standard input is read one line at a time, circularly, for each instance of “-”.


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)"
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I don't recall the exact best practice for this (will need to do some research), but I feel like we should be using something like mktemp.

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
Comment on lines +138 to +140
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Given that the case construct we're using here only differentiates between "OK" and other, it feels like this could be structued differently. And is the stdout stream the only way to communicate success or failure over this channel?

') in
OK)
builtin echo "Terminfo setup complete." >&2
[[ -n "$target" ]] && "$_CACHE" add "$target"
env+=(TERM=xterm-ghostty)
ctrl+=(-o "ControlPath=$cpath")
Copy link
Collaborator

@jparise jparise Jun 26, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think this is the only place we add to ctrl. If so, maybe we just add to opts instread and drop ctrl?

;;
*) builtin echo "Warning: Failed to install terminfo." >&2 ;;
esac
fi
else
builtin 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 && ! " ${env[*]} " =~ " TERM=" ]] && env+=(TERM=xterm-256color)
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I'm not sure " TERM=" will match if it's the first entry in env (leading space).

fi

# Execute
if [[ ${#env[@]} -gt 0 ]]; then
env "${env[@]}" ssh "${opts[@]}" "${ctrl[@]}" "$@"
else
builtin command ssh "${opts[@]}" "${ctrl[@]}" "$@"
fi
}
fi

# Import bash-preexec, safe to do multiple times
builtin source "$(dirname -- "${BASH_SOURCE[0]}")/bash-preexec.sh"

Expand Down
Loading