aboutsummaryrefslogtreecommitdiff
path: root/vendor/libghostty-vt/src/cli
diff options
context:
space:
mode:
authorOgulcan Celik <ogulcancelik@gmail.com>2026-06-01 23:05:23 +0300
committerOgulcan Celik <ogulcancelik@gmail.com>2026-06-01 23:05:51 +0300
commit5bd21b7ed468b5ff4d1a5b2ed7e211f401eb138a (patch)
tree672fa4625cca0ecb3e9376fc7f8e33d7b0e9deef /vendor/libghostty-vt/src/cli
parent32abb3776668ee768200019c170956f67fe33a86 (diff)
chore: update libghostty vendor
refs #276
Diffstat (limited to 'vendor/libghostty-vt/src/cli')
-rw-r--r--vendor/libghostty-vt/src/cli/ghostty.zig12
-rw-r--r--vendor/libghostty-vt/src/cli/ssh-cache/DiskCache.zig412
-rw-r--r--vendor/libghostty-vt/src/cli/ssh-cache/Entry.zig55
-rw-r--r--vendor/libghostty-vt/src/cli/ssh.zig635
-rw-r--r--vendor/libghostty-vt/src/cli/ssh_cache.zig454
-rw-r--r--vendor/libghostty-vt/src/cli/toggle_quick_terminal.zig62
6 files changed, 1356 insertions, 274 deletions
diff --git a/vendor/libghostty-vt/src/cli/ghostty.zig b/vendor/libghostty-vt/src/cli/ghostty.zig
index 3acb9004..6e836851 100644
--- a/vendor/libghostty-vt/src/cli/ghostty.zig
+++ b/vendor/libghostty-vt/src/cli/ghostty.zig
@@ -11,6 +11,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 ssh = @import("ssh.zig");
const ssh_cache = @import("ssh_cache.zig");
const edit_config = @import("edit_config.zig");
const show_config = @import("show_config.zig");
@@ -20,6 +21,7 @@ const crash_report = @import("crash_report.zig");
const show_face = @import("show_face.zig");
const boo = @import("boo.zig");
const new_window = @import("new_window.zig");
+const toggle_quick_terminal = @import("toggle_quick_terminal.zig");
/// Special commands that can be invoked via CLI flags. These are all
/// invoked by using `+<action>` as a CLI flag. The only exception is
@@ -46,6 +48,9 @@ pub const Action = enum {
/// List keybind actions
@"list-actions",
+ /// Wrap `ssh` to configure Ghostty terminal integration on remote hosts
+ ssh,
+
/// Manage SSH terminfo cache for automatic remote host setup
@"ssh-cache",
@@ -73,6 +78,9 @@ pub const Action = enum {
// Use IPC to tell the running Ghostty to open a new window.
@"new-window",
+ // Use IPC to tell the running Ghostty to toggle the quick terminal.
+ @"toggle-quick-terminal",
+
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
@@ -144,6 +152,7 @@ pub const Action = enum {
.@"list-colors" => try list_colors.run(alloc),
.@"list-actions" => try list_actions.run(alloc),
.@"ssh-cache" => try ssh_cache.run(alloc),
+ .ssh => try ssh.run(alloc),
.@"edit-config" => try edit_config.run(alloc),
.@"show-config" => try show_config.run(alloc),
.@"explain-config" => try explain_config.run(alloc),
@@ -152,6 +161,7 @@ pub const Action = enum {
.@"show-face" => try show_face.run(alloc),
.boo => try boo.run(alloc),
.@"new-window" => try new_window.run(alloc),
+ .@"toggle-quick-terminal" => try toggle_quick_terminal.run(alloc),
};
}
@@ -184,6 +194,7 @@ pub const Action = enum {
.@"list-colors" => list_colors.Options,
.@"list-actions" => list_actions.Options,
.@"ssh-cache" => ssh_cache.Options,
+ .ssh => ssh.Options,
.@"edit-config" => edit_config.Options,
.@"show-config" => show_config.Options,
.@"explain-config" => explain_config.Options,
@@ -192,6 +203,7 @@ pub const Action = enum {
.@"show-face" => show_face.Options,
.boo => boo.Options,
.@"new-window" => new_window.Options,
+ .@"toggle-quick-terminal" => toggle_quick_terminal.Options,
};
}
}
diff --git a/vendor/libghostty-vt/src/cli/ssh-cache/DiskCache.zig b/vendor/libghostty-vt/src/cli/ssh-cache/DiskCache.zig
index 6fa74b43..bb11e74f 100644
--- a/vendor/libghostty-vt/src/cli/ssh-cache/DiskCache.zig
+++ b/vendor/libghostty-vt/src/cli/ssh-cache/DiskCache.zig
@@ -17,14 +17,6 @@ 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`.
@@ -33,7 +25,7 @@ pub const Error = error{ CacheIsLocked, HostnameIsInvalid };
pub fn defaultPath(
alloc: Allocator,
program: []const u8,
-) DefaultPathError![]const u8 {
+) ![]const u8 {
const state_dir: []const u8 = xdg.state(
alloc,
.{ .subdir = program },
@@ -55,27 +47,15 @@ pub fn clear(self: DiskCache) !void {
};
}
-pub const AddResult = enum { added, updated };
-
-pub const AddError = std.fs.Dir.MakeError ||
- std.fs.Dir.StatFileError ||
- std.fs.File.OpenError ||
- std.fs.File.ChmodError ||
- std.io.Reader.LimitedAllocError ||
- FixupPermissionsError ||
- ReadEntriesError ||
- WriteCacheFileError ||
- Error;
-
-/// Add or update a hostname entry in the cache.
-/// Returns AddResult.added for new entries or AddResult.updated for existing ones.
+/// Add or update an entry in the cache, recording `timestamp` (Unix seconds).
/// The cache file is created if it doesn't exist with secure permissions (0600).
pub fn add(
self: DiskCache,
alloc: Allocator,
- hostname: []const u8,
-) AddError!AddResult {
- if (!isValidCacheKey(hostname)) return error.HostnameIsInvalid;
+ key: []const u8,
+ timestamp: i64,
+) !void {
+ if (!isValidCacheKey(key)) return error.InvalidCacheKey;
// Create cache directory if needed
if (std.fs.path.dirname(self.path)) |dir| {
@@ -107,58 +87,49 @@ pub fn add(
// Lock
// Causes a compile failure in the Zig std library on Windows, see:
// https://github.com/ziglang/zig/issues/18430
- if (comptime builtin.os.tag != .windows) _ = file.tryLock(.exclusive) catch return error.CacheIsLocked;
+ if (comptime builtin.os.tag != .windows) _ = file.tryLock(.exclusive) catch return error.CacheLocked;
defer if (comptime builtin.os.tag != .windows) 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);
+ // Update the timestamp of an existing entry, or insert a new one. For a
+ // new entry, dupe both strings up front so a failed allocation never
+ // leaves a half-built slot (borrowed key, undefined value) for the
+ // `deinitEntries` defer to walk.
+ if (entries.getPtr(key)) |existing| {
+ existing.timestamp = timestamp;
+ } else {
+ const key_copy = try alloc.dupe(u8, key);
+ errdefer alloc.free(key_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(),
+ try entries.put(key_copy, .{
+ .hostname = key_copy,
+ .timestamp = 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(entries, null);
- return result;
+ try self.writeCacheFile(entries);
}
-pub const RemoveError = std.fs.File.OpenError ||
- FixupPermissionsError ||
- ReadEntriesError ||
- WriteCacheFileError ||
- Error;
-
-/// Remove a hostname entry from the cache.
-/// No error is returned if the hostname doesn't exist or the cache file is missing.
+/// Remove an entry from the cache. Returns true if an entry was removed,
+/// false if the key wasn't present (or the cache file is missing).
pub fn remove(
self: DiskCache,
alloc: Allocator,
- hostname: []const u8,
-) RemoveError!void {
- if (!isValidCacheKey(hostname)) return error.HostnameIsInvalid;
+ key: []const u8,
+) !bool {
+ if (!isValidCacheKey(key)) return error.InvalidCacheKey;
// Open our file
const file = std.fs.openFileAbsolute(
self.path,
.{ .mode = .read_write },
) catch |err| switch (err) {
- error.FileNotFound => return,
+ error.FileNotFound => return false,
else => return err,
};
defer file.close();
@@ -167,7 +138,7 @@ pub fn remove(
// Lock
// Causes a compile failure in the Zig std library on Windows, see:
// https://github.com/ziglang/zig/issues/18430
- if (comptime builtin.os.tag != .windows) _ = file.tryLock(.exclusive) catch return error.CacheIsLocked;
+ if (comptime builtin.os.tag != .windows) _ = file.tryLock(.exclusive) catch return error.CacheLocked;
defer if (comptime builtin.os.tag != .windows) file.unlock();
// Read existing entries
@@ -175,27 +146,73 @@ pub fn remove(
defer deinitEntries(alloc, &entries);
// Remove the entry if it exists and ensure we free the memory
- if (entries.fetchRemove(hostname)) |kv| {
+ const removed = if (entries.fetchRemove(key)) |kv| removed: {
assert(kv.key.ptr == kv.value.hostname.ptr);
alloc.free(kv.value.hostname);
alloc.free(kv.value.terminfo_version);
- }
+ break :removed true;
+ } else false;
- try self.writeCacheFile(entries, null);
+ try self.writeCacheFile(entries);
+ return removed;
}
-pub const ContainsError = std.fs.File.OpenError ||
- ReadEntriesError ||
- error{HostnameIsInvalid};
+/// Remove all entries older than `max_age_s` seconds and return how many
+/// were pruned. Returns zero (and nothing written) if the cache file is
+/// missing.
+pub fn prune(
+ self: DiskCache,
+ alloc: Allocator,
+ max_age_s: u64,
+) !usize {
+ const file = std.fs.openFileAbsolute(
+ self.path,
+ .{ .mode = .read_write },
+ ) catch |err| switch (err) {
+ error.FileNotFound => return 0,
+ else => return err,
+ };
+ defer file.close();
+ try fixupPermissions(file);
+
+ // Lock
+ // Causes a compile failure in the Zig std library on Windows, see:
+ // https://github.com/ziglang/zig/issues/18430
+ if (comptime builtin.os.tag != .windows) _ = file.tryLock(.exclusive) catch return error.CacheLocked;
+ defer if (comptime builtin.os.tag != .windows) file.unlock();
-/// Check if a hostname exists in the cache.
+ // Read existing entries
+ var entries = try readEntries(alloc, file);
+ defer deinitEntries(alloc, &entries);
+
+ // Drop expired entries from the map, then persist what remains.
+ const now = std.time.timestamp();
+ var expired: std.ArrayList([]const u8) = .empty;
+ defer expired.deinit(alloc);
+ var iter = entries.iterator();
+ while (iter.next()) |kv| {
+ const age_s = now -| kv.value_ptr.timestamp;
+ if (age_s > max_age_s) try expired.append(alloc, kv.key_ptr.*);
+ }
+ for (expired.items) |key| {
+ const kv = entries.fetchRemove(key).?;
+ assert(kv.key.ptr == kv.value.hostname.ptr);
+ alloc.free(kv.value.hostname);
+ alloc.free(kv.value.terminfo_version);
+ }
+
+ try self.writeCacheFile(entries);
+ return expired.items.len;
+}
+
+/// Check if a key exists in the cache.
/// Returns false if the cache file doesn't exist.
pub fn contains(
self: DiskCache,
alloc: Allocator,
- hostname: []const u8,
-) ContainsError!bool {
- if (!isValidCacheKey(hostname)) return error.HostnameIsInvalid;
+ key: []const u8,
+) !bool {
+ if (!isValidCacheKey(key)) return error.InvalidCacheKey;
// Open our file
const file = std.fs.openFileAbsolute(
@@ -211,12 +228,10 @@ pub fn contains(
var entries = try readEntries(alloc, file);
defer deinitEntries(alloc, &entries);
- return entries.contains(hostname);
+ return entries.contains(key);
}
-pub const FixupPermissionsError = (std.fs.File.StatError || std.fs.File.ChmodError);
-
-fn fixupPermissions(file: std.fs.File) FixupPermissionsError!void {
+fn fixupPermissions(file: std.fs.File) !void {
// Windows does not support chmod
if (comptime builtin.os.tag == .windows) return;
@@ -228,18 +243,10 @@ fn fixupPermissions(file: std.fs.File) FixupPermissionsError!void {
}
}
-pub const WriteCacheFileError = std.fs.Dir.OpenError ||
- std.fs.AtomicFile.InitError ||
- std.fs.AtomicFile.FlushError ||
- std.fs.AtomicFile.FinishError ||
- Entry.FormatError ||
- error{InvalidCachePath};
-
fn writeCacheFile(
self: DiskCache,
entries: std.StringHashMap(Entry),
- expire_days: ?u32,
-) WriteCacheFileError!void {
+) !void {
const cache_dir = std.fs.path.dirname(self.path) orelse return error.InvalidCachePath;
const cache_basename = std.fs.path.basename(self.path);
@@ -255,8 +262,6 @@ fn writeCacheFile(
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(&atomic_file.file_writer.interface);
}
@@ -299,12 +304,10 @@ pub fn deinitEntries(
entries.deinit();
}
-pub const ReadEntriesError = std.mem.Allocator.Error || std.io.Reader.LimitedAllocError;
-
fn readEntries(
alloc: Allocator,
file: std.fs.File,
-) ReadEntriesError!std.StringHashMap(Entry) {
+) !std.StringHashMap(Entry) {
var reader = file.reader(&.{});
const content = try reader.interface.allocRemaining(
alloc,
@@ -313,26 +316,35 @@ fn readEntries(
defer alloc.free(content);
var entries = std.StringHashMap(Entry).init(alloc);
+ errdefer deinitEntries(alloc, &entries);
+
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);
+ // Dupe both strings up front, before inserting, so the map never
+ // holds a half-built entry (a borrowed key or a freed/undefined
+ // value) for `deinitEntries` to walk if an allocation fails.
+ var hostname: ?[]u8 = try alloc.dupe(u8, entry.hostname);
+ errdefer if (hostname) |h| alloc.free(h);
+ var terminfo: ?[]u8 = try alloc.dupe(u8, entry.terminfo_version);
+ errdefer if (terminfo) |t| alloc.free(t);
- const gop = try entries.getOrPut(hostname);
+ const gop = try entries.getOrPut(hostname.?);
if (!gop.found_existing) {
- const terminfo_copy = try alloc.dupe(u8, entry.terminfo_version);
+ // New entry: transfer both copies to the map.
gop.value_ptr.* = .{
- .hostname = hostname,
+ .hostname = hostname.?,
.timestamp = entry.timestamp,
- .terminfo_version = terminfo_copy,
+ .terminfo_version = terminfo.?,
};
+ hostname = null;
+ terminfo = null;
} else {
- // Don't need the copy since entry already exists
- alloc.free(hostname);
+ // Duplicate key: the map keeps its existing key, so free ours.
+ alloc.free(hostname.?);
+ hostname = null;
// Handle duplicate entries - keep newer timestamp
if (entry.timestamp > gop.value_ptr.timestamp) {
@@ -340,13 +352,15 @@ fn readEntries(
if (!std.mem.eql(
u8,
gop.value_ptr.terminfo_version,
- entry.terminfo_version,
+ terminfo.?,
)) {
alloc.free(gop.value_ptr.terminfo_version);
- const terminfo_copy = try alloc.dupe(u8, entry.terminfo_version);
- gop.value_ptr.terminfo_version = terminfo_copy;
+ gop.value_ptr.terminfo_version = terminfo.?;
+ terminfo = null;
}
}
+ if (terminfo) |t| alloc.free(t);
+ terminfo = null;
}
}
@@ -354,7 +368,7 @@ fn readEntries(
}
// Supports both standalone hostnames and user@hostname format
-fn isValidCacheKey(key: []const u8) bool {
+pub fn isValidCacheKey(key: []const u8) bool {
if (key.len == 0) return false;
// Check for user@hostname format
@@ -452,33 +466,23 @@ test "disk cache operations" {
const path = try tmp.dir.realpathAlloc(alloc, "cache");
defer alloc.free(path);
- // Setup our cache
+ // Setup our cache. Adding the same key twice exercises both the new
+ // and existing-entry paths.
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"),
- );
+ try cache.add(alloc, "example.com", std.time.timestamp());
+ try cache.add(alloc, "example.com", std.time.timestamp());
+ 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"),
- );
+ // Remove reports that it removed the entry, and a second remove of the
+ // same key reports nothing to remove.
+ try testing.expect(try cache.remove(alloc, "example.com"));
+ try testing.expect(!try cache.remove(alloc, "example.com"));
+ try testing.expect(!(try cache.contains(alloc, "example.com")));
+ try cache.add(alloc, "example.com", std.time.timestamp());
}
test "disk cache cleans up temp files" {
@@ -494,8 +498,8 @@ test "disk cache cleans up temp files" {
defer alloc.free(cache_path);
const cache: DiskCache = .{ .path = cache_path };
- try testing.expectEqual(AddResult.added, try cache.add(alloc, "example.com"));
- try testing.expectEqual(AddResult.added, try cache.add(alloc, "example.org"));
+ try cache.add(alloc, "example.com", std.time.timestamp());
+ try cache.add(alloc, "example.org", std.time.timestamp());
// Verify only the cache file exists and no temp files left behind
var count: usize = 0;
@@ -507,6 +511,170 @@ test "disk cache cleans up temp files" {
try testing.expectEqual(1, count);
}
+test "disk cache prune" {
+ const testing = std.testing;
+ const alloc = testing.allocator;
+
+ var tmp = testing.tmpDir(.{});
+ defer tmp.cleanup();
+ const tmp_path = try tmp.dir.realpathAlloc(alloc, ".");
+ defer alloc.free(tmp_path);
+ const cache_path = try std.fs.path.join(alloc, &.{ tmp_path, "cache" });
+ defer alloc.free(cache_path);
+
+ const cache: DiskCache = .{ .path = cache_path };
+
+ // Back-date one entry an hour old and one 100 days old.
+ const day = std.time.s_per_day;
+ const hour = std.time.s_per_hour;
+ const now = std.time.timestamp();
+ try cache.add(alloc, "recent.com", now - hour);
+ try cache.add(alloc, "old.com", now - 100 * day);
+
+ // Prune entries older than 90 days: only old.com goes.
+ try testing.expectEqual(@as(usize, 1), try cache.prune(alloc, 90 * day));
+ try testing.expect(try cache.contains(alloc, "recent.com"));
+ try testing.expect(!try cache.contains(alloc, "old.com"));
+
+ // Pruning again removes nothing.
+ try testing.expectEqual(@as(usize, 0), try cache.prune(alloc, 90 * day));
+
+ // Sub-day granularity: a 30-minute max age prunes the hour-old entry.
+ try testing.expectEqual(@as(usize, 1), try cache.prune(alloc, 30 * std.time.s_per_min));
+ try testing.expect(!try cache.contains(alloc, "recent.com"));
+}
+
+test "disk cache prune missing file" {
+ const testing = std.testing;
+ const alloc = testing.allocator;
+
+ var tmp = testing.tmpDir(.{});
+ defer tmp.cleanup();
+
+ const tmp_path = try tmp.dir.realpathAlloc(alloc, ".");
+ defer alloc.free(tmp_path);
+ const cache_path = try std.fs.path.join(alloc, &.{ tmp_path, "cache" });
+ defer alloc.free(cache_path);
+
+ const cache: DiskCache = .{ .path = cache_path };
+ try testing.expectEqual(@as(usize, 0), try cache.prune(alloc, 30));
+}
+
+test "disk cache reads duplicate keys" {
+ const testing = std.testing;
+ const alloc = testing.allocator;
+
+ var tmp = testing.tmpDir(.{});
+ defer tmp.cleanup();
+
+ // Exercise readEntries' found_existing branch: replace the existing
+ // key with the updated entry and ensure (via testing.allocator) that
+ // we don't double-free or leak.
+ {
+ var file = try tmp.dir.createFile("cache", .{});
+ defer file.close();
+ var buf: [256]u8 = undefined;
+ var file_writer = file.writer(&buf);
+ try file_writer.interface.writeAll(
+ "example.com|100|xterm-ghostty\nexample.com|200|xterm-newer\n",
+ );
+ try file_writer.interface.flush();
+ }
+ const path = try tmp.dir.realpathAlloc(alloc, "cache");
+ defer alloc.free(path);
+
+ const cache: DiskCache = .{ .path = path };
+ var entries = try cache.list(alloc);
+ defer deinitEntries(alloc, &entries);
+
+ try testing.expectEqual(@as(u32, 1), entries.count());
+ const entry = entries.get("example.com").?;
+ try testing.expectEqual(@as(i64, 200), entry.timestamp);
+ try testing.expectEqualStrings("xterm-newer", entry.terminfo_version);
+}
+
+test "disk cache reads survive allocation failure" {
+ const testing = std.testing;
+
+ var tmp = testing.tmpDir(.{});
+ defer tmp.cleanup();
+
+ // Exercise a populated cache containing a duplicate key to ensure
+ // that we hit all of the possible allocation behaviors below.
+ {
+ var file = try tmp.dir.createFile("cache", .{});
+ defer file.close();
+ var buf: [256]u8 = undefined;
+ var file_writer = file.writer(&buf);
+ try file_writer.interface.writeAll(
+ "a.com|100|xterm-ghostty\n" ++
+ "b.com|100|xterm-ghostty\n" ++
+ "c.com|100|xterm-ghostty\n" ++
+ "a.com|200|xterm-newer\n",
+ );
+ try file_writer.interface.flush();
+ }
+ const path = try tmp.dir.realpathAlloc(testing.allocator, "cache");
+ defer testing.allocator.free(path);
+
+ const cache: DiskCache = .{ .path = path };
+
+ // Fail the Nth allocation for every N until the read completes. The
+ // FailingAllocator is backed by testing.allocator so we also ensure
+ // that we don't double-free or leak; this can only completely succeed
+ // or fail with OutOfMemory.
+ var fail_index: usize = 0;
+ while (true) : (fail_index += 1) {
+ var failing = std.testing.FailingAllocator.init(
+ testing.allocator,
+ .{ .fail_index = fail_index },
+ );
+ const alloc = failing.allocator();
+
+ if (cache.list(alloc)) |entries_const| {
+ var entries = entries_const;
+ deinitEntries(alloc, &entries);
+ // Reached a run with no induced failure: every path covered.
+ if (!failing.has_induced_failure) break;
+ } else |err| {
+ try testing.expectEqual(error.OutOfMemory, err);
+ }
+ }
+}
+
+test "disk cache add survives allocation failure" {
+ const testing = std.testing;
+
+ var tmp = testing.tmpDir(.{});
+ defer tmp.cleanup();
+ const tmp_path = try tmp.dir.realpathAlloc(testing.allocator, ".");
+ defer testing.allocator.free(tmp_path);
+ const path = try std.fs.path.join(testing.allocator, &.{ tmp_path, "cache" });
+ defer testing.allocator.free(path);
+
+ const cache: DiskCache = .{ .path = path };
+
+ // Fail the Nth allocation for every N until add completes. A failed add
+ // must not leak or leave a half-built map entry. The FailingAllocator
+ // is backed by testing.allocator to catch either. Each iteration starts
+ // from a clean cache file.
+ var fail_index: usize = 0;
+ while (true) : (fail_index += 1) {
+ std.fs.cwd().deleteFile(path) catch {};
+ var failing = std.testing.FailingAllocator.init(
+ testing.allocator,
+ .{ .fail_index = fail_index },
+ );
+ const alloc = failing.allocator();
+
+ if (cache.add(alloc, "user@example.com", 100)) |_| {
+ if (!failing.has_induced_failure) break;
+ } else |err| {
+ try testing.expectEqual(error.OutOfMemory, err);
+ }
+ }
+}
+
test isValidHost {
const testing = std.testing;
diff --git a/vendor/libghostty-vt/src/cli/ssh-cache/Entry.zig b/vendor/libghostty-vt/src/cli/ssh-cache/Entry.zig
index b586161f..158694f9 100644
--- a/vendor/libghostty-vt/src/cli/ssh-cache/Entry.zig
+++ b/vendor/libghostty-vt/src/cli/ssh-cache/Entry.zig
@@ -42,61 +42,6 @@ pub fn format(self: Entry, writer: *std.Io.Writer) FormatError!void {
);
}
-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;
diff --git a/vendor/libghostty-vt/src/cli/ssh.zig b/vendor/libghostty-vt/src/cli/ssh.zig
new file mode 100644
index 00000000..76bfb10e
--- /dev/null
+++ b/vendor/libghostty-vt/src/cli/ssh.zig
@@ -0,0 +1,635 @@
+const std = @import("std");
+const Allocator = std.mem.Allocator;
+const ArenaAllocator = std.heap.ArenaAllocator;
+const cli_args = @import("args.zig");
+const diagnostics = @import("diagnostics.zig");
+const Action = @import("ghostty.zig").Action;
+const DiskCache = @import("ssh_cache.zig").DiskCache;
+const internal_os = @import("../os/main.zig");
+const ghostty_terminfo = @import("../terminfo/main.zig").ghostty;
+
+const log = std.log.scoped(.ssh);
+
+const usage =
+ \\Usage: ghostty +ssh [flags] [--] <ssh args...>
+ \\
+ \\Flags:
+ \\ --forward-env[=bool] Enable TERM / SendEnv forwarding. Default: true.
+ \\ --terminfo[=bool] Install Ghostty terminfo on first connect. Default: true.
+ \\ --cache[=bool] Use the terminfo install cache. Default: true.
+ \\ --ssh=<path> Path to the ssh binary. Default: first `ssh` on PATH.
+ \\ --verbose Print +ssh status lines to stderr.
+ \\ --help Show full help.
+ \\
+ \\ssh flags and the destination go after +ssh's own flags (or after `--`).
+ \\
+;
+
+pub const Options = struct {
+ /// Set by the CLI parser for deinit.
+ _arena: ?ArenaAllocator = null,
+
+ /// Maps to the `ssh-env` shell integration feature.
+ @"forward-env": bool = true,
+
+ /// Maps to the `ssh-terminfo` shell integration feature.
+ terminfo: bool = true,
+
+ /// When false, both cache read and write are bypassed.
+ cache: bool = true,
+
+ /// The wrapped `ssh` binary.
+ /// `/`-containing values are treated as paths; otherwise resolved via PATH.
+ ssh: []const u8 = "ssh",
+
+ /// When true, print verbose output to stderr.
+ verbose: bool = false,
+
+ /// Arguments passed through to `ssh` verbatim. Populated by
+ /// `parseManuallyHook` when we reach the first non-flag argument (or
+ /// an explicit `--`).
+ _ssh_args: std.ArrayList([]const u8) = .empty,
+
+ /// Enables arg parsing diagnostics so unknown flags become
+ /// diagnostics rather than fatal errors.
+ _diagnostics: diagnostics.DiagnosticList = .{},
+
+ pub fn deinit(self: *Options) void {
+ if (self._arena) |arena| arena.deinit();
+ self.* = undefined;
+ }
+
+ /// Enables `-h` and `--help` to work.
+ pub fn help(_: Options) !void {
+ return Action.help_error;
+ }
+
+ /// Manual parse hook. For each argument:
+ /// - If it's a literal `--`, consume everything after it as ssh
+ /// args and stop parsing.
+ /// - If it doesn't start with `--`, this is the start of the ssh
+ /// argv. Consume this arg and everything after as ssh args and
+ /// stop parsing.
+ /// - Otherwise (a `--foo` arg), return true so the generic parser
+ /// handles it as one of our own flags.
+ pub fn parseManuallyHook(
+ self: *Options,
+ alloc: Allocator,
+ arg: []const u8,
+ iter: anytype,
+ ) Allocator.Error!bool {
+ if (std.mem.eql(u8, arg, "--")) {
+ while (iter.next()) |rest| {
+ try self._ssh_args.append(alloc, try alloc.dupe(u8, rest));
+ }
+ return false;
+ }
+
+ if (!std.mem.startsWith(u8, arg, "--")) {
+ try self._ssh_args.append(alloc, try alloc.dupe(u8, arg));
+ while (iter.next()) |rest| {
+ try self._ssh_args.append(alloc, try alloc.dupe(u8, rest));
+ }
+ return false;
+ }
+
+ return true;
+ }
+};
+
+/// Wrap `ssh` to automatically configure Ghostty terminal integration on
+/// remote hosts.
+///
+/// Any arguments that aren't recognized as `+ssh` flags are passed to
+/// the real `ssh` binary unchanged. You can use `--` as an explicit
+/// disambiguator if needed, though it's almost never required: `ssh`
+/// has no long flags, and `+ssh` defines no short flags, so there's
+/// nothing to collide.
+///
+/// This is typically called via Ghostty's shell integration. When
+/// `shell-integration-features` includes `ssh-env` or `ssh-terminfo`,
+/// each shell defines an `ssh` function that runs:
+///
+/// ghostty +ssh <flags> -- "$@"
+///
+/// You can also run `ghostty +ssh` directly, or alias it yourself (e.g.
+/// `alias ssh='ghostty +ssh --'`) if you prefer not to use the shell
+/// integration.
+///
+/// `+ssh` performs up to two pieces of setup before launching `ssh`:
+///
+/// 1. **Environment forwarding** (`--forward-env`). Sets `TERM` to
+/// `xterm-256color` and requests `SendEnv` forwarding of
+/// `COLORTERM`, `TERM_PROGRAM`, and `TERM_PROGRAM_VERSION` so the
+/// remote shell can still detect that it's running inside Ghostty.
+/// The remote `sshd_config` must list these in `AcceptEnv` for
+/// forwarding to succeed.
+///
+/// 2. **Terminfo install** (`--terminfo`). On the first connection to a
+/// given destination, installs Ghostty's terminfo entry on the remote
+/// host using `infocmp -x xterm-ghostty | ssh tic -x -` over a
+/// shared `ControlMaster` connection. Successful installs are cached
+/// (see `ghostty +ssh-cache`) so subsequent connections skip this
+/// step. When terminfo is successfully installed or already cached,
+/// `TERM` is set to `xterm-ghostty` instead of `xterm-256color`.
+///
+/// If `--terminfo` install fails (e.g. `tic` not available on the
+/// remote, filesystem permissions), a warning is logged and the
+/// connection continues with `TERM=xterm-256color`.
+///
+/// Flags:
+///
+/// * `--forward-env=<bool>`: Enable `TERM` / `SendEnv` environment
+/// forwarding. Default: `true`.
+///
+/// * `--terminfo=<bool>`: Enable automatic terminfo install on first
+/// connection. Default: `true`.
+///
+/// * `--cache=<bool>`: Use the terminfo install cache. Default: `true`.
+/// When `false`, both the cache read (skip-if-installed) and the
+/// cache write (record-on-success) are bypassed, and every
+/// connection performs the install. To one-shot reinstall a single
+/// host while keeping the cache in use, prefer `ghostty +ssh-cache
+/// --remove=<host>` followed by a normal connection.
+///
+/// * `--ssh=<path>`: Path to the `ssh` binary to execute. Default: the
+/// first `ssh` found on `PATH`.
+///
+/// * `--verbose`: Print +ssh status lines to stderr, and surface
+/// remote stderr during the terminfo install.
+///
+/// Examples:
+///
+/// # Basic invocation using defaults:
+/// ghostty +ssh user@example.com
+///
+/// # Forward Ghostty env vars but skip the terminfo install:
+/// ghostty +ssh --terminfo=false user@example.com
+///
+/// # `ssh` flags (short-form `-p`, etc.) pass through unchanged:
+/// ghostty +ssh -p 2222 -i ~/.ssh/id_ed25519 user@example.com
+///
+/// # Use `--` explicitly if your ssh args might collide with our flags:
+/// ghostty +ssh -- --some-rare-ssh-arg user@example.com
+///
+/// Pass `--verbose` to see what `+ssh` is doing. For cache inspection
+/// and management, see `ghostty +ssh-cache`.
+///
+/// Available since: 1.4.0
+pub fn run(alloc_gpa: Allocator) !u8 {
+ var opts: Options = .{};
+ defer opts.deinit();
+
+ {
+ var iter = try cli_args.argsIterator(alloc_gpa);
+ defer iter.deinit();
+ try cli_args.parse(Options, alloc_gpa, &opts, &iter);
+ }
+
+ var stderr_buffer: [1024]u8 = undefined;
+ var stderr_file: std.fs.File = .stderr();
+ var stderr_writer = stderr_file.writer(&stderr_buffer);
+ const stderr = &stderr_writer.interface;
+
+ // Any diagnostic from the arg parser is an unknown flag or bad
+ // value. Reject loudly — silently forwarding `--typo` to ssh would
+ // produce confusing downstream errors.
+ if (!opts._diagnostics.empty()) {
+ for (opts._diagnostics.items()) |diag| {
+ if (diag.key.len > 0) {
+ stderr.print(
+ "Error: unknown flag `--{s}`.\n",
+ .{diag.key},
+ ) catch {};
+ } else {
+ stderr.print("Error: {s}\n", .{diag.message}) catch {};
+ }
+ }
+ stderr.print("\n{s}", .{usage}) catch {};
+ stderr.flush() catch {};
+ return 2;
+ }
+
+ const result = runInner(alloc_gpa, &opts, stderr);
+
+ stderr.flush() catch {};
+ return result;
+}
+
+fn runInner(
+ gpa: Allocator,
+ opts: *const Options,
+ stderr: *std.Io.Writer,
+) !u8 {
+ var arena = ArenaAllocator.init(gpa);
+ defer arena.deinit();
+ const alloc = arena.allocator();
+
+ if (opts._ssh_args.items.len == 0) {
+ try stderr.print("Error: no ssh arguments provided.\n\n{s}", .{usage});
+ return 2;
+ }
+
+ const session: struct {
+ term: []const u8,
+ to_cache: ?struct { cache: DiskCache, dest: []const u8 } = null,
+ } = session: {
+ if (!opts.terminfo) break :session .{ .term = "xterm-256color" };
+
+ const dest = resolveDestination(alloc, opts.ssh, opts._ssh_args.items) orelse {
+ warnPrint(stderr, "could not resolve ssh destination; skipping terminfo install", .{});
+ break :session .{ .term = "xterm-256color" };
+ };
+
+ const cache: ?DiskCache = if (opts.cache) cache: {
+ const path = DiskCache.defaultPath(alloc, "ghostty") catch |err| {
+ warnPrint(stderr, "ghostty terminfo cache unavailable: {}", .{err});
+ break :session .{ .term = "xterm-256color" };
+ };
+ break :cache .{ .path = path };
+ } else null;
+
+ if (cache) |c| {
+ if (c.contains(alloc, dest) catch false) {
+ verbosePrint(opts, stderr, "dest: {s} (cached, skipping install)", .{dest});
+ break :session .{ .term = "xterm-ghostty" };
+ } else {
+ verbosePrint(opts, stderr, "dest: {s} (not cached, will install)", .{dest});
+ }
+ } else {
+ verbosePrint(opts, stderr, "dest: {s} (cache disabled, will install)", .{dest});
+ }
+
+ stderr.print("Setting up xterm-ghostty terminfo on {s}...\n", .{dest}) catch {};
+ stderr.flush() catch {};
+
+ installRemoteTerminfo(alloc, opts, stderr) catch |err| {
+ warnPrint(stderr, "failed to install terminfo: {}", .{err});
+ break :session .{ .term = "xterm-256color" };
+ };
+ break :session .{
+ .term = "xterm-ghostty",
+ .to_cache = if (cache) |c| .{ .cache = c, .dest = dest } else null,
+ };
+ };
+
+ // Build the full argv: [ssh, ...our opts, ...user args]
+ const env_opts: []const []const u8 = if (opts.@"forward-env") env_opts: {
+ const set_term = try std.fmt.allocPrint(
+ alloc,
+ "SetEnv=TERM={s}",
+ .{session.term},
+ );
+ break :env_opts &.{
+ "-o", set_term,
+ "-o", "SendEnv=COLORTERM",
+ "-o", "SendEnv=TERM_PROGRAM",
+ "-o", "SendEnv=TERM_PROGRAM_VERSION",
+ };
+ } else &.{};
+ const argv = try std.mem.concat(alloc, []const u8, &.{
+ &.{opts.ssh},
+ env_opts,
+ opts._ssh_args.items,
+ });
+ verbosePrint(opts, stderr, "exec: {f}", .{Joined{ .items = argv }});
+
+ const exit_code = childExec(alloc, argv) catch |err| {
+ try stderr.print("Error: failed to run {s}: {}\n", .{ argv[0], err });
+ return 1;
+ };
+ verbosePrint(opts, stderr, "exit: {d}", .{exit_code});
+
+ // Attempt to cache (if needed) on a successful ssh execution.
+ if (exit_code == 0) if (session.to_cache) |entry| {
+ if (entry.cache.add(alloc, entry.dest, std.time.timestamp())) |_| {
+ verbosePrint(opts, stderr, "cache: wrote {s}", .{entry.dest});
+ } else |err| {
+ log.debug("cache add failed for '{s}': {}", .{ entry.dest, err });
+ }
+ };
+
+ return exit_code;
+}
+
+/// Log to `.ssh` and, if `--verbose`, also print to stderr.
+fn verbosePrint(
+ opts: *const Options,
+ stderr: *std.Io.Writer,
+ comptime fmt: []const u8,
+ args: anytype,
+) void {
+ log.debug(fmt, args);
+ if (!opts.verbose) return;
+ stderr.print("+ssh: " ++ fmt ++ "\n", args) catch return;
+ stderr.flush() catch return;
+}
+
+/// Log a warning and also print a `Warning: <msg>` line to stderr.
+fn warnPrint(
+ stderr: *std.Io.Writer,
+ comptime fmt: []const u8,
+ args: anytype,
+) void {
+ log.warn(fmt, args);
+ stderr.print("Warning: " ++ fmt ++ "\n", args) catch return;
+ stderr.flush() catch return;
+}
+
+/// Space-joined items, formattable as `{f}`.
+const Joined = struct {
+ items: []const []const u8,
+
+ pub fn format(self: Joined, writer: *std.Io.Writer) !void {
+ for (self.items, 0..) |a, i| {
+ if (i > 0) try writer.writeByte(' ');
+ try writer.writeAll(a);
+ }
+ }
+
+ test {
+ const testing = std.testing;
+ var buf: [128]u8 = undefined;
+ {
+ var w: std.Io.Writer = .fixed(&buf);
+ try w.print("{f}", .{Joined{ .items = &.{} }});
+ try testing.expectEqualStrings("", buf[0..w.end]);
+ }
+ {
+ var w: std.Io.Writer = .fixed(&buf);
+ try w.print("{f}", .{Joined{ .items = &.{"only"} }});
+ try testing.expectEqualStrings("only", buf[0..w.end]);
+ }
+ {
+ var w: std.Io.Writer = .fixed(&buf);
+ try w.print("{f}", .{Joined{ .items = &.{ "a", "b", "c" } }});
+ try testing.expectEqualStrings("a b c", buf[0..w.end]);
+ }
+ }
+};
+
+fn checkExit(term: std.process.Child.Term, label: []const u8) error{ChildFailed}!void {
+ switch (term) {
+ .Exited => |rc| if (rc != 0) {
+ log.warn("{s} exited with non-zero status: {d}", .{ label, rc });
+ return error.ChildFailed;
+ },
+ else => {
+ log.warn("{s} terminated abnormally: {}", .{ label, term });
+ return error.ChildFailed;
+ },
+ }
+}
+
+/// Run `ssh -G <args>` and parse the output for `user` and `hostname`.
+/// Returns the resolved `user@hostname`, or null if the destination
+/// could not be resolved.
+fn resolveDestination(
+ alloc: Allocator,
+ ssh: []const u8,
+ args: []const []const u8,
+) ?[]const u8 {
+ const argv = std.mem.concat(alloc, []const u8, &.{
+ &.{ ssh, "-G" },
+ args,
+ }) catch return null;
+ const result = std.process.Child.run(.{
+ .allocator = alloc,
+ .argv = argv,
+ }) catch |err| {
+ log.warn("ssh -G spawn failed: {}", .{err});
+ return null;
+ };
+ checkExit(result.term, "ssh -G") catch return null;
+ return parseDestination(alloc, result.stdout);
+}
+
+/// Parse `ssh -G` output for `user` and `hostname` and return the
+/// formatted `user@hostname`. Returns null if either key is missing
+/// or formatting fails.
+fn parseDestination(alloc: Allocator, stdout: []const u8) ?[]const u8 {
+ var user: []const u8 = "";
+ var host: []const u8 = "";
+ var it = std.mem.tokenizeScalar(u8, stdout, '\n');
+ while (it.next()) |line| {
+ const space = std.mem.indexOfScalar(u8, line, ' ') orelse continue;
+ const key = line[0..space];
+ const value = line[space + 1 ..];
+ if (std.mem.eql(u8, key, "user")) {
+ user = value;
+ } else if (std.mem.eql(u8, key, "hostname")) {
+ host = value;
+ }
+ if (user.len > 0 and host.len > 0) break;
+ }
+
+ if (user.len == 0) {
+ log.warn("ssh -G output missing user", .{});
+ return null;
+ }
+ if (host.len == 0) {
+ log.warn("ssh -G output missing hostname", .{});
+ return null;
+ }
+
+ return std.fmt.allocPrint(alloc, "{s}@{s}", .{ user, host }) catch null;
+}
+
+/// Install Ghostty's terminfo on the remote host over a short-lived SSH
+/// ControlMaster connection. The master tears down with the client
+/// (`ControlPersist=no`) so no socket lingers.
+fn installRemoteTerminfo(
+ alloc: Allocator,
+ opts: *const Options,
+ stderr: *std.Io.Writer,
+) !void {
+ var buf: std.Io.Writer.Allocating = .init(alloc);
+ defer buf.deinit();
+ try ghostty_terminfo.encode(&buf.writer);
+ const terminfo = buf.written();
+
+ // ControlPath is in TMPDIR with a short, random basename. ssh uses
+ // ControlPath as the bind address for a Unix domain socket; macOS
+ // limits sockaddr_un.sun_path to ~104 bytes, so keeping the path
+ // short leaves margin.
+ const control_path = try internal_os.randomTmpPath(alloc, "ghostty-ssh-");
+ const control_path_opt = try std.fmt.allocPrint(
+ alloc,
+ "ControlPath={s}",
+ .{control_path},
+ );
+
+ // Under --verbose, let remote stderr through (the `tic` step is
+ // the most common failure source) and inherit ssh's stderr so it
+ // reaches the user's terminal. Other steps stay quiet either way.
+ const remote_script = if (opts.verbose)
+ \\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 - && exit 0
+ \\exit 1
+ else
+ \\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
+ ;
+
+ // Set up an SSH ControlMaster scoped to this single install:
+ // - ControlMaster=yes makes our client also act as the master,
+ // so `infocmp | ssh tic` runs over a single connection.
+ // - ControlPersist=no tears the master down when our client
+ // exits; no socket lingers on the remote side.
+ const argv = try std.mem.concat(alloc, []const u8, &.{
+ &.{opts.ssh},
+ &.{
+ "-o", "ControlMaster=yes",
+ "-o", "ControlPersist=no",
+ "-o", control_path_opt,
+ },
+ opts._ssh_args.items,
+ &.{remote_script},
+ });
+ verbosePrint(opts, stderr, "exec: {f}", .{Joined{ .items = argv }});
+
+ var child: std.process.Child = .init(argv, alloc);
+ child.stdin_behavior = .Pipe;
+ child.stdout_behavior = .Ignore;
+ child.stderr_behavior = if (opts.verbose) .Inherit else .Ignore;
+
+ child.spawn() catch |err| {
+ log.warn("terminfo install spawn failed: {}", .{err});
+ return error.InstallFailed;
+ };
+
+ if (child.stdin) |stdin| {
+ stdin.writeAll(terminfo) catch {};
+ stdin.close();
+ child.stdin = null;
+ }
+
+ const term = child.wait() catch |err| {
+ log.warn("terminfo install wait failed: {}", .{err});
+ return error.InstallFailed;
+ };
+ checkExit(term, "terminfo install") catch return error.InstallFailed;
+}
+
+/// Returns `128 + signum` for signal-killed children, matching shell convention.
+fn childExec(alloc: Allocator, argv: []const []const u8) !u8 {
+ var child: std.process.Child = .init(argv, alloc);
+ child.stdin_behavior = .Inherit;
+ child.stdout_behavior = .Inherit;
+ child.stderr_behavior = .Inherit;
+
+ try child.spawn();
+ const term = try child.wait();
+ return switch (term) {
+ .Exited => |rc| rc,
+ .Signal => |sig| @as(u8, 128) + @as(u8, @intCast(@min(sig, 127))),
+ .Stopped, .Unknown => 1,
+ };
+}
+
+fn parseTestArgs(alloc: Allocator, opts: *Options, line: []const u8) !void {
+ var iter = try std.process.ArgIteratorGeneral(.{}).init(alloc, line);
+ defer iter.deinit();
+ try cli_args.parse(Options, alloc, opts, &iter);
+}
+
+test "parseManuallyHook: bare destination starts ssh args" {
+ const testing = std.testing;
+ var opts: Options = .{};
+ defer opts.deinit();
+ try parseTestArgs(testing.allocator, &opts, "--terminfo=false user@example.com");
+ try testing.expectEqual(false, opts.terminfo);
+ try testing.expectEqual(true, opts.@"forward-env");
+ try testing.expectEqual(@as(usize, 1), opts._ssh_args.items.len);
+ try testing.expectEqualStrings("user@example.com", opts._ssh_args.items[0]);
+}
+
+test "parseManuallyHook: short ssh flags pass through verbatim" {
+ const testing = std.testing;
+ var opts: Options = .{};
+ defer opts.deinit();
+ try parseTestArgs(testing.allocator, &opts, "-p 2222 user@example.com");
+ try testing.expectEqual(@as(usize, 3), opts._ssh_args.items.len);
+ try testing.expectEqualStrings("-p", opts._ssh_args.items[0]);
+ try testing.expectEqualStrings("2222", opts._ssh_args.items[1]);
+ try testing.expectEqualStrings("user@example.com", opts._ssh_args.items[2]);
+}
+
+test "parseManuallyHook: explicit -- separator" {
+ const testing = std.testing;
+ var opts: Options = .{};
+ defer opts.deinit();
+ try parseTestArgs(
+ testing.allocator,
+ &opts,
+ "--verbose -- --some-rare-ssh-arg user@example.com",
+ );
+ try testing.expectEqual(true, opts.verbose);
+ try testing.expectEqual(@as(usize, 2), opts._ssh_args.items.len);
+ try testing.expectEqualStrings("--some-rare-ssh-arg", opts._ssh_args.items[0]);
+ try testing.expectEqualStrings("user@example.com", opts._ssh_args.items[1]);
+}
+
+test "parseDestination: typical ssh -G output" {
+ const testing = std.testing;
+ var arena = ArenaAllocator.init(testing.allocator);
+ defer arena.deinit();
+ const stdout =
+ \\user alice
+ \\hostname example.com
+ \\port 22
+ \\identityfile ~/.ssh/id_ed25519
+ \\
+ ;
+ const result = parseDestination(arena.allocator(), stdout);
+ try testing.expectEqualStrings("alice@example.com", result.?);
+}
+
+test "parseDestination: hostname before user" {
+ const testing = std.testing;
+ var arena = ArenaAllocator.init(testing.allocator);
+ defer arena.deinit();
+ const stdout =
+ \\hostname example.com
+ \\port 22
+ \\user alice
+ \\
+ ;
+ const result = parseDestination(arena.allocator(), stdout);
+ try testing.expectEqualStrings("alice@example.com", result.?);
+}
+
+test "parseDestination: missing hostname returns null" {
+ const testing = std.testing;
+ var arena = ArenaAllocator.init(testing.allocator);
+ defer arena.deinit();
+ const stdout = "user alice\nport 22\n";
+ try testing.expectEqual(@as(?[]const u8, null), parseDestination(arena.allocator(), stdout));
+}
+
+test "parseDestination: missing user returns null" {
+ const testing = std.testing;
+ var arena = ArenaAllocator.init(testing.allocator);
+ defer arena.deinit();
+ const stdout = "hostname example.com\nport 22\n";
+ try testing.expectEqual(@as(?[]const u8, null), parseDestination(arena.allocator(), stdout));
+}
+
+test "parseDestination: empty input returns null" {
+ const testing = std.testing;
+ var arena = ArenaAllocator.init(testing.allocator);
+ defer arena.deinit();
+ try testing.expectEqual(@as(?[]const u8, null), parseDestination(arena.allocator(), ""));
+}
+
+test "parseDestination: IPv6 hostname" {
+ const testing = std.testing;
+ var arena = ArenaAllocator.init(testing.allocator);
+ defer arena.deinit();
+ const stdout = "user alice\nhostname ::1\n";
+ const result = parseDestination(arena.allocator(), stdout);
+ try testing.expectEqualStrings("alice@::1", result.?);
+}
diff --git a/vendor/libghostty-vt/src/cli/ssh_cache.zig b/vendor/libghostty-vt/src/cli/ssh_cache.zig
index d3ee658a..83031e8e 100644
--- a/vendor/libghostty-vt/src/cli/ssh_cache.zig
+++ b/vendor/libghostty-vt/src/cli/ssh_cache.zig
@@ -3,6 +3,7 @@ const fs = std.fs;
const Allocator = std.mem.Allocator;
const args = @import("args.zig");
const Action = @import("ghostty.zig").Action;
+const Duration = @import("../config.zig").Config.Duration;
pub const Entry = @import("ssh-cache/Entry.zig");
pub const DiskCache = @import("ssh-cache/DiskCache.zig");
@@ -10,8 +11,7 @@ pub const Options = struct {
clear: bool = false,
add: ?[]const u8 = null,
remove: ?[]const u8 = null,
- host: ?[]const u8 = null,
- @"expire-days": ?u32 = null,
+ prune: ?Duration = null,
pub fn deinit(self: *Options) void {
_ = self;
@@ -25,27 +25,36 @@ pub const Options = struct {
/// 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 `+ssh` action installs Ghostty's terminfo on remote hosts and records
+/// each success in this cache so it doesn't re-upload on later connections.
+/// (`+ssh` runs automatically from the shell integration when
+/// `shell-integration-features` includes `ssh-terminfo`.) This command
+/// inspects and maintains that cache.
///
-/// 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.
+/// The cache stores destinations (a hostname or user@hostname) along with
+/// timestamps.
///
-/// 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.
+/// A positional destination queries the cache: `user@hostname` shows that
+/// exact entry, while a bare `hostname` shows every cached entry for that
+/// host regardless of user. With no destination and no action, the entire
+/// cache is listed. A query that matches nothing exits 1.
+///
+/// At most one action (`--clear`, `--add`, `--remove`, or `--prune`) may be
+/// specified, and not together with a positional destination; combining them
+/// is an error.
+///
+/// `--prune` takes a duration with unit suffixes (`s`, `m`, `h`, `d`, `w`,
+/// `y`) and removes every entry older than it, e.g. `--prune=30d`,
+/// `--prune=6h`, `--prune=1y`.
///
/// 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
+/// ghostty +ssh-cache # List all cached destinations
+/// ghostty +ssh-cache user@example.com # Show that destination
+/// ghostty +ssh-cache example.com # Show all users on that host
+/// ghostty +ssh-cache --add=user@example.com # Manually add a destination
+/// ghostty +ssh-cache --remove=user@example.com # Remove a destination
+/// ghostty +ssh-cache --prune=30d # Remove entries older than 30 days
+/// ghostty +ssh-cache --clear # Clear entire cache
pub fn run(alloc_gpa: Allocator) !u8 {
var arena = std.heap.ArenaAllocator.init(alloc_gpa);
defer arena.deinit();
@@ -54,12 +63,6 @@ pub fn run(alloc_gpa: Allocator) !u8 {
var opts: Options = .{};
defer opts.deinit();
- {
- var iter = try args.argsIterator(alloc_gpa);
- defer iter.deinit();
- try args.parse(Options, alloc_gpa, &opts, &iter);
- }
-
var stdout_buffer: [1024]u8 = undefined;
var stdout_file: std.fs.File = .stdout();
var stdout_writer = stdout_file.writer(&stdout_buffer);
@@ -70,7 +73,66 @@ pub fn run(alloc_gpa: Allocator) !u8 {
var stderr_writer = stderr_file.writer(&stderr_buffer);
const stderr = &stderr_writer.interface;
- const result = runInner(alloc, opts, stdout, stderr);
+ // The cache is queried by a positional destination (`user@host` or a
+ // bare `host`). `args.parse` rejects non-`--` tokens, so we lift the
+ // positional out here and parse only the remaining flags. `--host=X`
+ // is accepted as a deprecated spelling of the positional (it was the
+ // original shipped flag name).
+ var query: ?[]const u8 = null;
+ var flags: std.ArrayList([]const u8) = .empty;
+ {
+ var iter = try args.argsIterator(alloc_gpa);
+ defer iter.deinit();
+ while (iter.next()) |arg| {
+ const is_host_flag = std.mem.startsWith(u8, arg, "--host=");
+ if (is_host_flag) {
+ try stderr.print(
+ "Warning: --host is deprecated; pass the destination " ++
+ "directly, e.g. `ghostty +ssh-cache {s}`.\n",
+ .{arg["--host=".len..]},
+ );
+ }
+ const dest: ?[]const u8 = if (is_host_flag)
+ arg["--host=".len..]
+ else if (!std.mem.startsWith(u8, arg, "-"))
+ arg
+ else
+ null;
+
+ if (dest) |d| {
+ if (query != null) {
+ try stderr.print(
+ "Error: only one destination may be specified.\n",
+ .{},
+ );
+ stderr.flush() catch {};
+ return 2;
+ }
+ query = try alloc.dupe(u8, d);
+ } else {
+ try flags.append(alloc, try alloc.dupe(u8, arg));
+ }
+ }
+ }
+
+ {
+ var iter = args.sliceIterator(flags.items);
+ args.parse(Options, alloc_gpa, &opts, &iter) catch |err| switch (err) {
+ error.InvalidField => {
+ try stderr.print("Error: unknown flag.\n", .{});
+ stderr.flush() catch {};
+ return 2;
+ },
+ error.InvalidValue, error.ValueRequired => {
+ try stderr.print("Error: invalid flag value.\n", .{});
+ stderr.flush() catch {};
+ return 2;
+ },
+ else => return err,
+ };
+ }
+
+ const result = runInner(alloc, opts, query, stdout, stderr);
// Flushing *shouldn't* fail but...
stdout.flush() catch {};
@@ -81,103 +143,126 @@ pub fn run(alloc_gpa: Allocator) !u8 {
pub fn runInner(
alloc: Allocator,
opts: Options,
+ query: ?[]const u8,
stdout: *std.Io.Writer,
stderr: *std.Io.Writer,
) !u8 {
+ // At most one action may be specified, and a query (positional
+ // destination) is itself an action.
+ const action_count =
+ @as(usize, @intFromBool(opts.clear)) +
+ @intFromBool(opts.add != null) +
+ @intFromBool(opts.remove != null) +
+ @intFromBool(opts.prune != null) +
+ @intFromBool(query != null);
+ if (action_count > 1) {
+ try stderr.print(
+ "Error: only one of a destination, --clear, --add, --remove, " ++
+ "or --prune may be specified.\n",
+ .{},
+ );
+ return 2;
+ }
+
// 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 cache.clear();
- try stdout.print("Cache cleared.\n", .{});
return 0;
}
- if (opts.add) |host| {
- 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;
+ if (opts.add) |dest| {
+ cache.add(alloc, dest, std.time.timestamp()) catch |err| switch (err) {
+ error.InvalidCacheKey => {
+ try stderr.print(
+ "Error: Invalid destination '{s}' (expected hostname or user@hostname)\n",
+ .{dest},
+ );
+ return 2;
},
else => {
try stderr.print(
"Error: Unable to add '{s}' to cache. Error: {}\n",
- .{ host, err },
+ .{ dest, err },
);
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| {
- 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;
+ if (opts.remove) |dest| {
+ const removed = cache.remove(alloc, dest) catch |err| switch (err) {
+ error.InvalidCacheKey => {
+ try stderr.print(
+ "Error: Invalid destination '{s}' (expected hostname or user@hostname)\n",
+ .{dest},
+ );
+ return 2;
},
else => {
try stderr.print(
"Error: Unable to remove '{s}' from cache. Error: {}\n",
- .{ host, err },
+ .{ dest, err },
);
return 1;
},
};
- try stdout.print("Removed '{s}' from cache.\n", .{host});
+ // Silence on success; a no-op removal is an error (exit 1).
+ if (!removed) {
+ try stderr.print("Error: '{s}' is not in the cache.\n", .{dest});
+ return 1;
+ }
return 0;
}
- if (opts.host) |host| {
- 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) {
- try stdout.print(
- "'{s}' has Ghostty terminfo installed.\n",
- .{host},
+ if (opts.prune) |max_age| {
+ const max_age_s = max_age.duration / std.time.ns_per_s;
+ if (max_age_s == 0) {
+ try stderr.print(
+ "Error: --prune requires a duration of at least one second.\n",
+ .{},
);
- return 0;
- } else {
- try stdout.print(
- "'{s}' does not have Ghostty terminfo installed.\n",
- .{host},
- );
- return 1;
+ return 2;
}
+ const pruned = cache.prune(alloc, max_age_s) catch |err| {
+ try stderr.print("Error: Unable to prune cache. Error: {}\n", .{err});
+ return 1;
+ };
+ try stdout.print("Pruned cache entries: {d}\n", .{pruned});
+ return 0;
}
- // Default action: list all hosts
var entries = try cache.list(alloc);
defer DiskCache.deinitEntries(alloc, &entries);
+
+ // A positional query filters the listing: an exact `user@host` match,
+ // or every entry on a bare `host`.
+ if (query) |q| {
+ if (!DiskCache.isValidCacheKey(q)) {
+ try stderr.print(
+ "Error: Invalid destination '{s}' (expected hostname or user@hostname)\n",
+ .{q},
+ );
+ return 2;
+ }
+
+ var matches: std.StringHashMap(Entry) = .init(alloc);
+ defer matches.deinit();
+ var iter = entries.iterator();
+ while (iter.next()) |kv| {
+ const key = kv.key_ptr.*;
+ if (matchesQuery(key, q)) try matches.put(key, kv.value_ptr.*);
+ }
+
+ if (matches.count() == 0) return 1;
+ try listEntries(alloc, &matches, stdout);
+ return 0;
+ }
+
+ // List all destinations by default.
try listEntries(alloc, &entries, stdout);
return 0;
}
@@ -187,10 +272,7 @@ fn listEntries(
entries: *const std.StringHashMap(Entry),
writer: *std.Io.Writer,
) !void {
- if (entries.count() == 0) {
- try writer.print("No hosts in cache.\n", .{});
- return;
- }
+ if (entries.count() == 0) return;
// Sort entries by hostname for consistent output
var items: std.ArrayList(Entry) = .empty;
@@ -207,22 +289,200 @@ fn listEntries(
}
}.lessThan);
- try writer.print("Cached hosts ({d}):\n", .{items.items.len});
- const now = std.time.timestamp();
+ // Align the timestamp column by padding destinations to the widest.
+ var widest: usize = 0;
+ for (items.items) |entry| {
+ widest = @max(widest, entry.hostname.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 });
- }
+ try writer.print("{s}", .{entry.hostname});
+ try writer.splatByteAll(' ', widest - entry.hostname.len + 2);
+
+ var iso_buf: [20]u8 = undefined;
+ var age_buf: [32]u8 = undefined;
+ try writer.print("{s} ({s})\n", .{
+ formatTimestamp(&iso_buf, entry.timestamp),
+ relativeAge(&age_buf, now, entry.timestamp),
+ });
+ }
+}
+
+/// Whether a cache `key` matches a positional `query`. A `user@host` query
+/// (containing `@`) matches one exact key; a bare `host` query matches every
+/// key on that host regardless of user, comparing against the key's host
+/// component (everything after its first `@`, or the whole key if userless).
+fn matchesQuery(key: []const u8, query: []const u8) bool {
+ if (std.mem.indexOfScalar(u8, query, '@') != null) {
+ return std.mem.eql(u8, key, query);
}
+
+ const at = std.mem.indexOfScalar(u8, key, '@');
+ const host = if (at) |i| key[i + 1 ..] else key;
+ return std.mem.eql(u8, host, query);
+}
+
+test matchesQuery {
+ const testing = std.testing;
+
+ // Exact user@host: only the identical key.
+ try testing.expect(matchesQuery("user@example.com", "user@example.com"));
+ try testing.expect(!matchesQuery("root@example.com", "user@example.com"));
+ try testing.expect(!matchesQuery("example.com", "user@example.com"));
+
+ // Bare host: every key on that host, plus a keyless entry for it.
+ try testing.expect(matchesQuery("user@example.com", "example.com"));
+ try testing.expect(matchesQuery("root@example.com", "example.com"));
+ try testing.expect(matchesQuery("example.com", "example.com"));
+ try testing.expect(!matchesQuery("user@other.com", "example.com"));
+}
+
+/// Format a Unix timestamp as an ISO-8601 UTC string
+/// (`YYYY-MM-DDTHH:MM:SSZ`) into `buf`, which must be at least 20 bytes.
+/// Out-of-range input is clamped so this can't crash on a garbage cache line.
+fn formatTimestamp(buf: []u8, timestamp: i64) []const u8 {
+ // Clamp to [epoch, last second of 9999-12-31Z]: `std.time.epoch`
+ // accumulates the year in a `u16` (panics beyond that), and the buffer
+ // only fits a 4-digit year.
+ const secs: u64 = @intCast(std.math.clamp(timestamp, 0, 253402300799));
+
+ const epoch = std.time.epoch;
+ const epoch_secs: epoch.EpochSeconds = .{ .secs = secs };
+ const day = epoch_secs.getEpochDay();
+ const year_day = day.calculateYearDay();
+ const month_day = year_day.calculateMonthDay();
+ const ds = epoch_secs.getDaySeconds();
+ return std.fmt.bufPrint(buf, "{d:0>4}-{d:0>2}-{d:0>2}T{d:0>2}:{d:0>2}:{d:0>2}Z", .{
+ year_day.year,
+ month_day.month.numeric(),
+ month_day.day_index + 1,
+ ds.getHoursIntoDay(),
+ ds.getMinutesIntoHour(),
+ ds.getSecondsIntoMinute(),
+ }) catch unreachable;
+}
+
+test formatTimestamp {
+ const testing = std.testing;
+ var buf: [20]u8 = undefined;
+
+ try testing.expectEqualStrings(
+ "2026-05-05T22:49:33Z",
+ formatTimestamp(&buf, 1778021373),
+ );
+
+ // Epoch.
+ try testing.expectEqualStrings(
+ "1970-01-01T00:00:00Z",
+ formatTimestamp(&buf, 0),
+ );
+
+ // Out-of-range inputs clamp instead of overflowing the [20]u8 /
+ // panicking inside std: negatives floor at the epoch, huge values cap
+ // at the last second of year 9999.
+ try testing.expectEqualStrings(
+ "1970-01-01T00:00:00Z",
+ formatTimestamp(&buf, -5),
+ );
+ try testing.expectEqualStrings(
+ "9999-12-31T23:59:59Z",
+ formatTimestamp(&buf, std.math.maxInt(i64)),
+ );
+}
+
+/// Format the age of `timestamp` (relative to `now`, both Unix seconds)
+/// as a coarse relative time into `buf`, e.g. "2w ago". Uses `Duration`'s
+/// unit vocabulary but keeps only the single largest unit for scannability.
+/// A non-positive age (timestamp at or after `now`) is "now".
+fn relativeAge(buf: []u8, now: i64, timestamp: i64) []const u8 {
+ // Saturating so a garbage timestamp can't overflow; clamp at 0 so a
+ // future timestamp becomes a zero age rather than going negative.
+ const age: u64 = @intCast(@max(0, now -| timestamp));
+ if (age == 0) return "now";
+
+ // Round down to the largest unit that fits, so Duration.format emits
+ // only that unit (e.g. 19d -> 2w, 90m -> 1h).
+ const units = [_]u64{
+ 365 * std.time.s_per_day, // y
+ std.time.s_per_week, // w
+ std.time.s_per_day, // d
+ std.time.s_per_hour, // h
+ std.time.s_per_min, // m
+ 1, // s
+ };
+ const unit = for (units) |u| {
+ if (age >= u) break u;
+ } else 1;
+
+ // Cap the age so `age * ns_per_s` can't overflow u64 (a garbage, e.g.
+ // hugely negative, timestamp otherwise yields an age near i64-max).
+ const max_age = std.math.maxInt(u64) / std.time.ns_per_s;
+ const rounded = @min(age, max_age) / unit * unit;
+ const d: Duration = .{ .duration = rounded * std.time.ns_per_s };
+ return std.fmt.bufPrint(buf, "{f} ago", .{d}) catch unreachable;
+}
+
+test relativeAge {
+ const testing = std.testing;
+ var buf: [32]u8 = undefined;
+ const now: i64 = 2_000_000_000; // fixed reference
+ const min = std.time.s_per_min;
+ const hour = std.time.s_per_hour;
+ const day = std.time.s_per_day;
+
+ // Out-of-range timestamps don't crash: a huge future one saturates to
+ // a non-positive age ("now"); a negative one is a large but real age.
+ try testing.expectEqualStrings("now", relativeAge(&buf, now, std.math.maxInt(i64)));
+ try testing.expectEqualStrings("63y ago", relativeAge(&buf, now, -100));
+
+ // A huge age (garbage timestamp) saturates the ns conversion instead of
+ // overflowing; it must not crash and must fit the buffer.
+ try testing.expect(std.mem.endsWith(u8, relativeAge(&buf, std.math.maxInt(i64), 0), " ago"));
+
+ // Future timestamp (clock skew) and same-instant read "now".
+ try testing.expectEqualStrings("now", relativeAge(&buf, now, now + 100));
+ try testing.expectEqualStrings("now", relativeAge(&buf, now, now));
+
+ // Only the single largest unit is kept (smaller units rounded away).
+ try testing.expectEqualStrings("30s ago", relativeAge(&buf, now, now - 30));
+ try testing.expectEqualStrings("1m ago", relativeAge(&buf, now, now - min));
+ try testing.expectEqualStrings("1m ago", relativeAge(&buf, now, now - 90)); // 90s -> 1m
+ try testing.expectEqualStrings("1h ago", relativeAge(&buf, now, now - hour));
+ try testing.expectEqualStrings("1h ago", relativeAge(&buf, now, now - (hour + 30 * min))); // 1h30m -> 1h
+ try testing.expectEqualStrings("1d ago", relativeAge(&buf, now, now - day));
+ try testing.expectEqualStrings("2w ago", relativeAge(&buf, now, now - 19 * day)); // 19d -> 2w
}
test {
_ = DiskCache;
_ = Entry;
}
+
+test "runInner rejects multiple actions" {
+ const testing = std.testing;
+ const alloc = testing.allocator;
+
+ var stdout: std.Io.Writer.Allocating = .init(alloc);
+ defer stdout.deinit();
+ var stderr: std.Io.Writer.Allocating = .init(alloc);
+ defer stderr.deinit();
+
+ // The check runs before any cache access, so it never touches disk.
+ const code = try runInner(alloc, .{
+ .add = "example.com",
+ .remove = "other.com",
+ }, null, &stdout.writer, &stderr.writer);
+
+ try testing.expectEqual(@as(u8, 2), code);
+ try testing.expectEqualStrings("", stdout.written());
+ try testing.expect(std.mem.indexOf(u8, stderr.written(), "only one") != null);
+
+ // A positional query is itself an action: query + a flag conflicts.
+ stderr.clearRetainingCapacity();
+ const code2 = try runInner(alloc, .{
+ .clear = true,
+ }, "example.com", &stdout.writer, &stderr.writer);
+ try testing.expectEqual(@as(u8, 2), code2);
+ try testing.expect(std.mem.indexOf(u8, stderr.written(), "only one") != null);
+}
diff --git a/vendor/libghostty-vt/src/cli/toggle_quick_terminal.zig b/vendor/libghostty-vt/src/cli/toggle_quick_terminal.zig
new file mode 100644
index 00000000..233700f7
--- /dev/null
+++ b/vendor/libghostty-vt/src/cli/toggle_quick_terminal.zig
@@ -0,0 +1,62 @@
+const std = @import("std");
+const Allocator = std.mem.Allocator;
+const Action = @import("../cli.zig").ghostty.Action;
+const apprt = @import("../apprt.zig");
+
+pub const Options = struct {
+ /// If set, connect to a custom instance of Ghostty.
+ class: ?[:0]const u8 = null,
+
+ pub fn deinit(self: *Options) void {
+ self.* = undefined;
+ }
+
+ /// Enables "-h" and "--help" to work.
+ pub fn help(self: Options) !void {
+ _ = self;
+ return Action.help_error;
+ }
+};
+
+/// The `+toggle-quick-terminal` command will use native platform IPC to toggle
+/// the quick terminal in a running instance of Ghostty.
+///
+/// If the `--class` flag is not set, the command will try and connect to the
+/// default running Ghostty instance. Otherwise it will contact a Ghostty
+/// instance configured with the given `class`.
+///
+/// On GTK, D-Bus activation must be properly configured. Ghostty does not need
+/// to be running, as D-Bus will handle launching a new instance if it is not
+/// already running.
+///
+/// Only supported on GTK.
+///
+/// Flags:
+///
+/// * `--class=<class>`: If set, connect to a custom instance of Ghostty.
+/// The class must be a valid GTK application ID.
+///
+/// Available since: 1.4.0
+pub fn run(alloc: Allocator) !u8 {
+ var buf: [256]u8 = undefined;
+ var stderr_writer = std.fs.File.stderr().writer(&buf);
+ const stderr = &stderr_writer.interface;
+
+ if (apprt.App.performIpc(
+ alloc,
+ .detect,
+ .toggle_quick_terminal,
+ {},
+ ) catch |err| switch (err) {
+ error.IPCFailed => {
+ return 1;
+ },
+ else => {
+ try stderr.print("Sending the IPC failed: {}\n", .{err});
+ return 1;
+ },
+ }) return 0;
+
+ try stderr.print("+toggle-quick-terminal is not supported on this platform.\n", .{});
+ return 1;
+}