pull/10776/merge
Jon Parise 2026-06-03 00:29:02 -04:00 committed by GitHub
commit 701fecf94b
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
9 changed files with 402 additions and 103 deletions

View File

@ -6,6 +6,7 @@ const formatter_file = @import("config/formatter_file.zig");
pub const Config = @import("config/Config.zig"); pub const Config = @import("config/Config.zig");
pub const conditional = @import("config/conditional.zig"); pub const conditional = @import("config/conditional.zig");
pub const io = @import("config/io.zig"); pub const io = @import("config/io.zig");
pub const shell = @import("config/shell.zig");
pub const string = @import("config/string.zig"); pub const string = @import("config/string.zig");
pub const edit = @import("config/edit.zig"); pub const edit = @import("config/edit.zig");
pub const url = @import("config/url.zig"); pub const url = @import("config/url.zig");
@ -40,7 +41,8 @@ pub const RepeatableString = Config.RepeatableString;
pub const RepeatableStringMap = @import("config/RepeatableStringMap.zig"); pub const RepeatableStringMap = @import("config/RepeatableStringMap.zig");
pub const RepeatablePath = Config.RepeatablePath; pub const RepeatablePath = Config.RepeatablePath;
pub const Path = Config.Path; pub const Path = Config.Path;
pub const ShellIntegrationFeatures = Config.ShellIntegrationFeatures; pub const ShellIntegration = shell.ShellIntegration;
pub const ShellIntegrationFeatures = shell.ShellIntegrationFeatures;
pub const WindowDecoration = Config.WindowDecoration; pub const WindowDecoration = Config.WindowDecoration;
pub const WindowPaddingColor = Config.WindowPaddingColor; pub const WindowPaddingColor = Config.WindowPaddingColor;
pub const BackgroundImagePosition = Config.BackgroundImagePosition; pub const BackgroundImagePosition = Config.BackgroundImagePosition;

View File

@ -27,6 +27,7 @@ const conditional = @import("conditional.zig");
const Conditional = conditional.Conditional; const Conditional = conditional.Conditional;
const file_load = @import("file_load.zig"); const file_load = @import("file_load.zig");
const formatterpkg = @import("formatter.zig"); const formatterpkg = @import("formatter.zig");
const shellpkg = @import("shell.zig");
const themepkg = @import("theme.zig"); const themepkg = @import("theme.zig");
const url = @import("url.zig"); const url = @import("url.zig");
pub const Key = @import("key.zig").Key; pub const Key = @import("key.zig").Key;
@ -862,9 +863,11 @@ palette: Palette = .{},
/// q`). Shell configurations will often request specific cursor styles. /// q`). Shell configurations will often request specific cursor styles.
/// ///
/// Note that shell integration will automatically set the cursor to a bar at /// Note that shell integration will automatically set the cursor to a bar at
/// a prompt, regardless of this configuration. You can disable that behavior /// a prompt, regardless of this configuration. You can customize the shell
/// by specifying `shell-integration-features = no-cursor` or disabling shell /// integration cursor style using `shell-integration-features` (e.g.,
/// integration entirely. /// `shell-integration-features = cursor:block` or `cursor:underline:steady`).
/// You can also disable the cursor feature entirely with
/// `shell-integration-features = no-cursor`.
/// ///
/// Valid values are: /// Valid values are:
/// ///
@ -2810,7 +2813,7 @@ keybind: Keybinds = .{},
/// * `bash`, `elvish`, `fish`, `nushell`, `zsh` - Use this specific shell injection scheme. /// * `bash`, `elvish`, `fish`, `nushell`, `zsh` - Use this specific shell injection scheme.
/// ///
/// The default value is `detect`. /// The default value is `detect`.
@"shell-integration": ShellIntegration = .detect, @"shell-integration": shellpkg.ShellIntegration = .detect,
/// Shell integration features to enable. These require our shell integration /// Shell integration features to enable. These require our shell integration
/// to be loaded, either automatically via shell-integration or manually. /// to be loaded, either automatically via shell-integration or manually.
@ -2824,7 +2827,20 @@ keybind: Keybinds = .{},
/// ///
/// Available features: /// Available features:
/// ///
/// * `cursor` - Set the cursor to a bar at the prompt. /// * `cursor` - Set the cursor style at the prompt. By default, this sets a bar
/// cursor using the cursor-style-blink configuration value.
///
/// Available cursor shapes: `bar`, `block`, `underline`
/// Available cursor styles: `blink`, `steady`, or omit to use cursor-style-blink
///
/// Examples:
/// - `cursor` or `cursor:bar` - bar cursor (respects cursor-style-blink)
/// - `cursor:block` - block cursor (respects cursor-style-blink)
/// - `cursor:underline:steady` - steady underline cursor
/// - `cursor:block:blink` - blinking block cursor
///
/// For convenience, `cursor:blink` and `cursor:steady` are supported as
/// shortcuts for `cursor:bar:blink` and `cursor:bar:steady`.
/// ///
/// * `sudo` - Set sudo wrapper to preserve terminfo. /// * `sudo` - Set sudo wrapper to preserve terminfo.
/// ///
@ -2855,7 +2871,7 @@ keybind: Keybinds = .{},
/// when both `ssh-env` and `ssh-terminfo` are enabled, Ghostty will install its /// 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 /// terminfo on remote hosts and use `xterm-ghostty` as TERM, falling back to
/// `xterm-256color` with environment variables if terminfo installation fails. /// `xterm-256color` with environment variables if terminfo installation fails.
@"shell-integration-features": ShellIntegrationFeatures = .{}, @"shell-integration-features": shellpkg.ShellIntegrationFeatures = .{},
/// Custom entries into the command palette. /// Custom entries into the command palette.
/// ///
@ -8657,27 +8673,6 @@ pub const MiddleClickAction = enum {
ignore, ignore,
}; };
/// Shell integration values
pub const ShellIntegration = enum {
none,
detect,
bash,
elvish,
fish,
nushell,
zsh,
};
/// Shell integration features
pub const ShellIntegrationFeatures = packed struct {
cursor: bool = true,
sudo: bool = false,
title: bool = true,
@"ssh-env": bool = false,
@"ssh-terminfo": bool = false,
path: bool = true,
};
pub const SplitPreserveZoom = packed struct { pub const SplitPreserveZoom = packed struct {
navigation: bool = false, navigation: bool = false,
}; };

325
src/config/shell.zig Normal file
View File

@ -0,0 +1,325 @@
const std = @import("std");
const Allocator = std.mem.Allocator;
const cli = @import("../cli.zig");
const formatterpkg = @import("formatter.zig");
/// Shell integration values
pub const ShellIntegration = enum {
none,
detect,
bash,
elvish,
fish,
nushell,
zsh,
};
/// Shell integration features
pub const ShellIntegrationFeatures = struct {
pub const Cursor = struct {
shape: Shape = .bar,
style: Style = .default,
pub const Shape = enum {
disabled,
bar,
block,
underline,
};
pub const Style = enum {
default, // cursor-style-blink
blink,
steady,
};
};
cursor: Cursor = .{},
path: bool = true,
@"ssh-env": bool = false,
@"ssh-terminfo": bool = false,
sudo: bool = false,
title: bool = true,
pub fn parseCLI(input: ?[]const u8) !ShellIntegrationFeatures {
const v = input orelse return error.ValueRequired;
var result: ShellIntegrationFeatures = .{};
// Handle "true" or "false" to toggle all features
if (std.mem.eql(u8, v, "true") or std.mem.eql(u8, v, "false")) {
const b = std.mem.eql(u8, v, "true");
result.cursor = if (b) .{} else .{ .shape = .disabled };
inline for (@typeInfo(ShellIntegrationFeatures).@"struct".fields) |field| {
if (field.type == bool) {
@field(result, field.name) = b;
}
}
return result;
}
var iter = std.mem.splitSequence(u8, v, ",");
loop: while (iter.next()) |part_raw| {
const trimmed = std.mem.trim(u8, part_raw, cli.args.whitespace);
// Handle cursor[:shape[:style]] syntax
if (std.mem.startsWith(u8, trimmed, "cursor")) {
var cursor_iter = std.mem.splitScalar(u8, trimmed, ':');
_ = cursor_iter.next(); // skip "cursor"
// Parse shape (if present)
if (cursor_iter.next()) |shape| {
// For convenience, "blink" or "steady" alone implies bar (default)
if (std.mem.eql(u8, shape, "blink")) {
result.cursor.shape = .bar;
result.cursor.style = .blink;
} else if (std.mem.eql(u8, shape, "steady")) {
result.cursor.shape = .bar;
result.cursor.style = .steady;
} else {
result.cursor.shape = std.meta.stringToEnum(Cursor.Shape, shape) orelse return error.InvalidValue;
}
// Parse style (if present)
if (cursor_iter.next()) |style| {
result.cursor.style = std.meta.stringToEnum(Cursor.Style, style) orelse return error.InvalidValue;
}
} else {
result.cursor = .{};
}
continue;
} else if (std.mem.eql(u8, trimmed, "no-cursor")) {
result.cursor.shape = .disabled;
continue;
}
const name, const value = part: {
const negation_prefix = "no-";
const trimmed_name = std.mem.trim(u8, part_raw, cli.args.whitespace);
if (std.mem.startsWith(u8, trimmed, negation_prefix)) {
break :part .{ trimmed_name[negation_prefix.len..], false };
} else {
break :part .{ trimmed_name, true };
}
};
inline for (@typeInfo(ShellIntegrationFeatures).@"struct".fields) |field| {
if (field.type == bool and std.mem.eql(u8, field.name, name)) {
@field(result, field.name) = value;
continue :loop;
}
}
// No field matched
return error.InvalidValue;
}
return result;
}
pub const FormatMode = enum {
/// Human-readable format for config output (e.g., "cursor:block:blink")
config,
/// Format for GHOSTTY_SHELL_FEATURES environment variable (e.g., "cursor:5")
env,
};
pub fn format(self: ShellIntegrationFeatures, writer: *std.Io.Writer, mode: FormatMode) anyerror!void {
const fields = comptime fields: {
const all_fields = @typeInfo(ShellIntegrationFeatures).@"struct".fields;
var sorted: [all_fields.len]std.builtin.Type.StructField = all_fields[0..].*;
const SortContext = struct {
fn lessThan(_: @This(), a: std.builtin.Type.StructField, b: std.builtin.Type.StructField) bool {
return std.ascii.orderIgnoreCase(a.name, b.name) == .lt;
}
};
std.mem.sortUnstable(std.builtin.Type.StructField, &sorted, SortContext{}, SortContext.lessThan);
break :fields sorted;
};
inline for (fields) |field| {
const enabled = switch (field.type) {
bool => @field(self, field.name),
Cursor => @field(self, field.name).shape != .disabled,
else => @compileError("unexpected field type in ShellIntegrationFeatures"),
};
if (enabled) {
if (writer.end > 0) try writer.writeByte(',');
try writer.writeAll(field.name);
switch (field.type) {
Cursor => {
const cursor = @field(self, field.name);
switch (mode) {
// DECSCUSR codes
.env => {
const decscusr: u8 = switch (cursor.shape) {
.disabled => unreachable,
.bar => switch (cursor.style) {
.default => unreachable,
.blink => 5,
.steady => 6,
},
.block => switch (cursor.style) {
.default => unreachable,
.blink => 1,
.steady => 2,
},
.underline => switch (cursor.style) {
.default => unreachable,
.blink => 3,
.steady => 4,
},
};
try writer.print(":{d}", .{decscusr});
},
// enum tag names
.config => {
try writer.writeByte(':');
try writer.writeAll(@tagName(cursor.shape));
if (cursor.style != .default) {
try writer.writeByte(':');
try writer.writeAll(@tagName(cursor.style));
}
},
}
},
else => {},
}
}
}
}
pub fn formatEntry(self: ShellIntegrationFeatures, formatter: formatterpkg.EntryFormatter) !void {
var buf: [128]u8 = undefined;
var writer: std.Io.Writer = .fixed(&buf);
try self.format(&writer, .config);
try formatter.formatEntry([]const u8, buf[0..writer.end]);
}
pub fn clone(self: ShellIntegrationFeatures, _: Allocator) error{}!ShellIntegrationFeatures {
return self;
}
pub fn equal(self: ShellIntegrationFeatures, other: ShellIntegrationFeatures) bool {
return std.meta.eql(self, other);
}
test "parseCLI" {
const testing = std.testing;
// Test that we can parse each bool field by name.
inline for (@typeInfo(ShellIntegrationFeatures).@"struct".fields) |field| {
if (comptime field.type == bool) {
const result = try ShellIntegrationFeatures.parseCLI(field.name);
try testing.expect(@field(result, field.name));
}
}
// Test that we can parse each bool field with "no-" prefix.
inline for (@typeInfo(ShellIntegrationFeatures).@"struct".fields) |field| {
if (comptime field.type == bool) {
const result = try ShellIntegrationFeatures.parseCLI("no-" ++ field.name);
try testing.expect(!@field(result, field.name));
}
}
// Test "true" enables all features
{
const result = try ShellIntegrationFeatures.parseCLI("true");
inline for (@typeInfo(ShellIntegrationFeatures).@"struct".fields) |field| {
switch (field.type) {
bool => try testing.expect(@field(result, field.name)),
Cursor => {
try testing.expectEqual(Cursor.Shape.bar, @field(result, field.name).shape);
try testing.expectEqual(Cursor.Style.default, @field(result, field.name).style);
},
else => {},
}
}
}
// Test "false" disables all features
{
const result = try ShellIntegrationFeatures.parseCLI("false");
inline for (@typeInfo(ShellIntegrationFeatures).@"struct".fields) |field| {
switch (field.type) {
bool => try testing.expect(!@field(result, field.name)),
Cursor => try testing.expectEqual(Cursor.Shape.disabled, @field(result, field.name).shape),
else => {},
}
}
}
// Test all comma-separated field names.
const all_input = comptime blk: {
const fields = @typeInfo(ShellIntegrationFeatures).@"struct".fields;
var buf: []const u8 = fields[0].name;
for (fields[1..]) |field| buf = buf ++ "," ++ field.name;
break :blk buf;
};
const all_features = try ShellIntegrationFeatures.parseCLI(all_input);
inline for (@typeInfo(ShellIntegrationFeatures).@"struct".fields) |field| {
const value = @field(all_features, field.name);
switch (field.type) {
bool => try testing.expect(value),
Cursor => {
try testing.expectEqual(Cursor.Shape.bar, value.shape);
try testing.expectEqual(Cursor.Style.default, value.style);
},
else => @compileError("unexpected field type in ShellIntegrationFeatures"),
}
}
// Cursor shapes and styles
inline for (@typeInfo(Cursor.Shape).@"enum".fields) |shape_field| {
const shape = @field(Cursor.Shape, shape_field.name);
if (comptime shape != .disabled) {
inline for (@typeInfo(Cursor.Style).@"enum".fields) |style_field| {
const style = @field(Cursor.Style, style_field.name);
const style_name = if (comptime style == .default) "" else ":" ++ style_field.name;
const input = "cursor:" ++ shape_field.name ++ style_name;
const result = try ShellIntegrationFeatures.parseCLI(input);
try testing.expectEqual(shape, result.cursor.shape);
try testing.expectEqual(style, result.cursor.style);
}
}
}
{
const result = try ShellIntegrationFeatures.parseCLI("cursor:blink");
try testing.expectEqual(Cursor.Shape.bar, result.cursor.shape);
try testing.expectEqual(Cursor.Style.blink, result.cursor.style);
}
{
const result = try ShellIntegrationFeatures.parseCLI("cursor:steady");
try testing.expectEqual(Cursor.Shape.bar, result.cursor.shape);
try testing.expectEqual(Cursor.Style.steady, result.cursor.style);
}
}
test "format" {
const testing = std.testing;
const testFormat = struct {
fn f(features: ShellIntegrationFeatures, mode: FormatMode, expected: []const u8) !void {
var buf: [128]u8 = undefined;
var writer: std.Io.Writer = .fixed(&buf);
try features.format(&writer, mode);
try testing.expectEqualStrings(expected, buf[0..writer.end]);
}
}.f;
// .config format
try testFormat(.{ .cursor = .{ .shape = .bar, .style = .steady }, .title = true }, .config, "cursor:bar:steady,path,title");
try testFormat(.{ .cursor = .{ .shape = .bar, .style = .blink }, .sudo = true }, .config, "cursor:bar:blink,path,sudo,title");
try testFormat(.{ .cursor = .{ .shape = .disabled }, .title = true }, .config, "path,title");
try testFormat(.{ .cursor = .{ .shape = .block, .style = .blink }, .title = true }, .config, "cursor:block:blink,path,title");
try testFormat(.{ .cursor = .{ .shape = .underline, .style = .default } }, .config, "cursor:underline,path,title");
try testFormat(.{ .cursor = .{ .shape = .bar, .style = .default } }, .config, "cursor:bar,path,title");
// .env format
try testFormat(.{ .cursor = .{ .shape = .bar, .style = .steady }, .title = true }, .env, "cursor:6,path,title");
try testFormat(.{ .cursor = .{ .shape = .bar, .style = .blink }, .sudo = true }, .env, "cursor:5,path,sudo,title");
try testFormat(.{ .cursor = .{ .shape = .disabled }, .title = true }, .env, "path,title");
try testFormat(.{ .cursor = .{ .shape = .block, .style = .blink }, .title = true }, .env, "cursor:1,path,title");
try testFormat(.{ .cursor = .{ .shape = .underline, .style = .steady } }, .env, "cursor:4,path,title");
}
};

View File

@ -158,9 +158,9 @@ function __ghostty_precmd() {
fi fi
# Cursor # Cursor
if [[ "$GHOSTTY_SHELL_FEATURES" == *"cursor"* ]]; then if [[ "$GHOSTTY_SHELL_FEATURES" == *"cursor:"* ]]; then
builtin local cursor=5 # blinking bar builtin local cursor="${GHOSTTY_SHELL_FEATURES#*cursor:}"
[[ "$GHOSTTY_SHELL_FEATURES" == *"cursor:steady"* ]] && cursor=6 # steady bar cursor="${cursor%%[, ]*}"
[[ "$PS1" != *"\[\e[${cursor} q\]"* ]] && PS1=$PS1"\[\e[${cursor} q\]" [[ "$PS1" != *"\[\e[${cursor} q\]"* ]] && PS1=$PS1"\[\e[${cursor} q\]"
[[ "$PS0" != *'\[\e[0 q\]'* ]] && PS0=$PS0'\[\e[0 q\]' # reset [[ "$PS0" != *'\[\e[0 q\]'* ]] && PS0=$PS0'\[\e[0 q\]' # reset

View File

@ -1,5 +1,6 @@
{ {
use platform use platform
use re
use str use str
# Clean up XDG_DATA_DIRS by removing GHOSTTY_SHELL_INTEGRATION_XDG_DIR # Clean up XDG_DATA_DIRS by removing GHOSTTY_SHELL_INTEGRATION_XDG_DIR
@ -100,15 +101,13 @@
set edit:after-readline = (conj $edit:after-readline $mark-output-start~) set edit:after-readline = (conj $edit:after-readline $mark-output-start~)
set edit:after-command = (conj $edit:after-command $mark-output-end~) set edit:after-command = (conj $edit:after-command $mark-output-end~)
if (str:contains $E:GHOSTTY_SHELL_FEATURES "cursor") { if (str:contains $E:GHOSTTY_SHELL_FEATURES "cursor:") {
var cursor = "5" # blinking bar var match = (re:find 'cursor:(\d+)' $E:GHOSTTY_SHELL_FEATURES)
if (has-value $features cursor:steady) { var cursor = $match[groups][1][text]
set cursor = "6" # steady bar
}
fn beam { printf "\e["$cursor" q" } fn set-cursor { printf "\e["$cursor" q" }
fn reset { printf "\e[0 q" } fn reset { printf "\e[0 q" }
set edit:before-readline = (conj $edit:before-readline $beam~) set edit:before-readline = (conj $edit:before-readline $set-cursor~)
set edit:after-readline = (conj $edit:after-readline {|_| reset }) set edit:after-readline = (conj $edit:after-readline {|_| reset })
} }
if (and (has-value $features path) (has-env GHOSTTY_BIN_DIR)) { if (and (has-value $features path) (has-env GHOSTTY_BIN_DIR)) {

View File

@ -72,12 +72,10 @@ function __ghostty_setup --on-event fish_prompt -d "Setup ghostty integration"
set -g __ghostty_prompt_start_mark "\e]133;A;click_events=1\a" set -g __ghostty_prompt_start_mark "\e]133;A;click_events=1\a"
end end
if string match -q 'cursor*' -- $features if string match -q 'cursor:*' -- $features
set -l cursor 5 # blinking bar set -l cursor (string replace -r '.*cursor:(\d+).*' '$1' -- $GHOSTTY_SHELL_FEATURES)
contains cursor:steady $features && set cursor 6 # steady bar
# Change the cursor to a beam on prompt. function __ghostty_set_cursor --on-event fish_prompt -V cursor -d "Set cursor shape"
function __ghostty_set_cursor_beam --on-event fish_prompt -V cursor -d "Set cursor shape"
if not functions -q fish_vi_cursor_handle if not functions -q fish_vi_cursor_handle
echo -en "\e[$cursor q" echo -en "\e[$cursor q"
end end
@ -168,8 +166,8 @@ function __ghostty_setup --on-event fish_prompt -d "Setup ghostty integration"
set --global fish_handle_reflow 1 set --global fish_handle_reflow 1
# Initial calls for first prompt # Initial calls for first prompt
if string match -q 'cursor*' -- $features if string match -q 'cursor:*' -- $features
__ghostty_set_cursor_beam __ghostty_set_cursor
end end
__ghostty_mark_prompt_start __ghostty_mark_prompt_start
__update_cwd_osc __update_cwd_osc

View File

@ -248,17 +248,21 @@ _ghostty_deferred_init() {
builtin print -rnu $_ghostty_fd \$'\\e]2;'\"\${1//[[:cntrl:]]}\"\$'\\a'" builtin print -rnu $_ghostty_fd \$'\\e]2;'\"\${1//[[:cntrl:]]}\"\$'\\a'"
fi fi
if [[ "$GHOSTTY_SHELL_FEATURES" == *"cursor"* ]]; then if [[ "$GHOSTTY_SHELL_FEATURES" == *"cursor:"* ]]; then
# Enable cursor shape changes depending on the current keymap. # Enable cursor shape changes depending on the current keymap.
# This implementation leaks blinking block cursor into external commands # This implementation leaks blinking block cursor into external commands
# executed from zle. For example, users of fzf-based widgets may find # executed from zle. For example, users of fzf-based widgets may find
# themselves with a blinking block cursor within fzf. # themselves with a blinking block cursor within fzf.
_ghostty_zle_line_init _ghostty_zle_line_finish _ghostty_zle_keymap_select() { _ghostty_zle_line_init _ghostty_zle_line_finish _ghostty_zle_keymap_select() {
builtin local steady=0 builtin local cursor="${GHOSTTY_SHELL_FEATURES#*cursor:}"
[[ "$GHOSTTY_SHELL_FEATURES" == *"cursor:steady"* ]] && steady=1 cursor="${cursor%%[, ]*}"
# vi command mode uses block cursor (1=blink, 2=steady)
builtin local block=$(( 1 + (cursor % 2 == 0) ))
case ${KEYMAP-} in case ${KEYMAP-} in
vicmd|visual) builtin print -nu "$_ghostty_fd" "\e[$(( 1 + steady )) q" ;; # block vicmd|visual) builtin print -nu "$_ghostty_fd" "\e[${block} q" ;;
*) builtin print -nu "$_ghostty_fd" "\e[$(( 5 + steady )) q" ;; # bar *) builtin print -nu "$_ghostty_fd" "\e[${cursor} q" ;;
esac esac
} }
# Restore the default shape before executing an external command # Restore the default shape before executing an external command

View File

@ -564,8 +564,8 @@ pub const Config = struct {
command: ?configpkg.Command = null, command: ?configpkg.Command = null,
env: EnvMap, env: EnvMap,
env_override: configpkg.RepeatableStringMap = .{}, env_override: configpkg.RepeatableStringMap = .{},
shell_integration: configpkg.Config.ShellIntegration = .detect, shell_integration: configpkg.ShellIntegration = .detect,
shell_integration_features: configpkg.Config.ShellIntegrationFeatures = .{}, shell_integration_features: configpkg.ShellIntegrationFeatures = .{},
cursor_blink: ?bool = null, cursor_blink: ?bool = null,
working_directory: ?[]const u8 = null, working_directory: ?[]const u8 = null,
resources_dir: ?[]const u8, resources_dir: ?[]const u8,

View File

@ -188,47 +188,16 @@ test detectShell {
pub fn setupFeatures( pub fn setupFeatures(
env: *EnvMap, env: *EnvMap,
features: config.ShellIntegrationFeatures, features: config.ShellIntegrationFeatures,
cursor_blink: bool, cursor_blink_default: bool,
) !void { ) !void {
const fields = @typeInfo(@TypeOf(features)).@"struct".fields; var buf: [128]u8 = undefined;
const capacity: usize = capacity: {
comptime var n: usize = fields.len - 1; // commas
inline for (fields) |field| n += field.name.len;
n += ":steady".len; // cursor value
break :capacity n;
};
var buf: [capacity]u8 = undefined;
var writer: std.Io.Writer = .fixed(&buf); var writer: std.Io.Writer = .fixed(&buf);
// Sort the fields so that the output is deterministic. This is var resolved = features;
// done at comptime so it has no runtime cost if (resolved.cursor.style == .default) {
const fields_sorted: [fields.len][]const u8 = comptime fields: { resolved.cursor.style = if (cursor_blink_default) .blink else .steady;
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 (writer.end > 0) try writer.writeByte(',');
try writer.writeAll(name);
if (std.mem.eql(u8, name, "cursor")) {
try writer.writeAll(if (cursor_blink) ":blink" else ":steady");
}
}
} }
try resolved.format(&writer, .env);
if (writer.end > 0) { if (writer.end > 0) {
try env.put("GHOSTTY_SHELL_FEATURES", buf[0..writer.end]); try env.put("GHOSTTY_SHELL_FEATURES", buf[0..writer.end]);
@ -242,16 +211,16 @@ test "setup features" {
defer arena.deinit(); defer arena.deinit();
const alloc = arena.allocator(); const alloc = arena.allocator();
// Test: all features enabled // Default features
{ {
var env = EnvMap.init(alloc); var env = EnvMap.init(alloc);
defer env.deinit(); defer env.deinit();
try setupFeatures(&env, .{ .cursor = true, .sudo = true, .title = true, .@"ssh-env" = true, .@"ssh-terminfo" = true, .path = true }, true); try setupFeatures(&env, .{}, true);
try testing.expectEqualStrings("cursor:blink,path,ssh-env,ssh-terminfo,sudo,title", env.get("GHOSTTY_SHELL_FEATURES").?); try testing.expectEqualStrings("cursor:5,path,title", env.get("GHOSTTY_SHELL_FEATURES").?);
} }
// Test: all features disabled // No features (all disabled)
{ {
var env = EnvMap.init(alloc); var env = EnvMap.init(alloc);
defer env.deinit(); defer env.deinit();
@ -260,29 +229,36 @@ test "setup features" {
try testing.expect(env.get("GHOSTTY_SHELL_FEATURES") == null); try testing.expect(env.get("GHOSTTY_SHELL_FEATURES") == null);
} }
// Test: mixed features // Cursor defaults (bar with style from config)
{ {
var env = EnvMap.init(alloc); var env = EnvMap.init(alloc);
defer env.deinit(); defer env.deinit();
var features: config.ShellIntegrationFeatures = std.mem.zeroes(config.ShellIntegrationFeatures);
try setupFeatures(&env, .{ .cursor = false, .sudo = true, .title = false, .@"ssh-env" = true, .@"ssh-terminfo" = false, .path = false }, true); features.cursor = .{};
try testing.expectEqualStrings("ssh-env,sudo", env.get("GHOSTTY_SHELL_FEATURES").?); try setupFeatures(&env, features, true);
try testing.expectEqualStrings("cursor:5", env.get("GHOSTTY_SHELL_FEATURES").?);
try setupFeatures(&env, features, false);
try testing.expectEqualStrings("cursor:6", env.get("GHOSTTY_SHELL_FEATURES").?);
} }
// Test: blinking cursor // Cursor with explicit shape
{ {
var env = EnvMap.init(alloc); var env = EnvMap.init(alloc);
defer env.deinit(); defer env.deinit();
try setupFeatures(&env, .{ .cursor = true, .sudo = false, .title = false, .@"ssh-env" = false, .@"ssh-terminfo" = false, .path = false }, true); var features: config.ShellIntegrationFeatures = std.mem.zeroes(config.ShellIntegrationFeatures);
try testing.expectEqualStrings("cursor:blink", env.get("GHOSTTY_SHELL_FEATURES").?); features.cursor = .{ .shape = .block, .style = .default };
try setupFeatures(&env, features, true);
try testing.expectEqualStrings("cursor:1", env.get("GHOSTTY_SHELL_FEATURES").?);
} }
// Test: steady cursor // Cursor with explicit shape and style
{ {
var env = EnvMap.init(alloc); var env = EnvMap.init(alloc);
defer env.deinit(); defer env.deinit();
try setupFeatures(&env, .{ .cursor = true, .sudo = false, .title = false, .@"ssh-env" = false, .@"ssh-terminfo" = false, .path = false }, false); var features: config.ShellIntegrationFeatures = std.mem.zeroes(config.ShellIntegrationFeatures);
try testing.expectEqualStrings("cursor:steady", env.get("GHOSTTY_SHELL_FEATURES").?); features.cursor = .{ .shape = .underline, .style = .steady };
try setupFeatures(&env, features, true);
try testing.expectEqualStrings("cursor:4", env.get("GHOSTTY_SHELL_FEATURES").?);
} }
} }