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 conditional = @import("config/conditional.zig");
pub const io = @import("config/io.zig");
pub const shell = @import("config/shell.zig");
pub const string = @import("config/string.zig");
pub const edit = @import("config/edit.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 RepeatablePath = Config.RepeatablePath;
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 WindowPaddingColor = Config.WindowPaddingColor;
pub const BackgroundImagePosition = Config.BackgroundImagePosition;

View File

@ -27,6 +27,7 @@ const conditional = @import("conditional.zig");
const Conditional = conditional.Conditional;
const file_load = @import("file_load.zig");
const formatterpkg = @import("formatter.zig");
const shellpkg = @import("shell.zig");
const themepkg = @import("theme.zig");
const url = @import("url.zig");
pub const Key = @import("key.zig").Key;
@ -862,9 +863,11 @@ palette: Palette = .{},
/// q`). Shell configurations will often request specific cursor styles.
///
/// Note that shell integration will automatically set the cursor to a bar at
/// a prompt, regardless of this configuration. You can disable that behavior
/// by specifying `shell-integration-features = no-cursor` or disabling shell
/// integration entirely.
/// a prompt, regardless of this configuration. You can customize the shell
/// integration cursor style using `shell-integration-features` (e.g.,
/// `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:
///
@ -2810,7 +2813,7 @@ keybind: Keybinds = .{},
/// * `bash`, `elvish`, `fish`, `nushell`, `zsh` - Use this specific shell injection scheme.
///
/// The default value is `detect`.
@"shell-integration": ShellIntegration = .detect,
@"shell-integration": shellpkg.ShellIntegration = .detect,
/// Shell integration features to enable. These require our shell integration
/// to be loaded, either automatically via shell-integration or manually.
@ -2824,7 +2827,20 @@ keybind: Keybinds = .{},
///
/// 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.
///
@ -2855,7 +2871,7 @@ keybind: Keybinds = .{},
/// when both `ssh-env` and `ssh-terminfo` are enabled, Ghostty will install its
/// terminfo on remote hosts and use `xterm-ghostty` as TERM, falling back to
/// `xterm-256color` with environment variables if terminfo installation fails.
@"shell-integration-features": ShellIntegrationFeatures = .{},
@"shell-integration-features": shellpkg.ShellIntegrationFeatures = .{},
/// Custom entries into the command palette.
///
@ -8657,27 +8673,6 @@ pub const MiddleClickAction = enum {
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 {
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
# Cursor
if [[ "$GHOSTTY_SHELL_FEATURES" == *"cursor"* ]]; then
builtin local cursor=5 # blinking bar
[[ "$GHOSTTY_SHELL_FEATURES" == *"cursor:steady"* ]] && cursor=6 # steady bar
if [[ "$GHOSTTY_SHELL_FEATURES" == *"cursor:"* ]]; then
builtin local cursor="${GHOSTTY_SHELL_FEATURES#*cursor:}"
cursor="${cursor%%[, ]*}"
[[ "$PS1" != *"\[\e[${cursor} q\]"* ]] && PS1=$PS1"\[\e[${cursor} q\]"
[[ "$PS0" != *'\[\e[0 q\]'* ]] && PS0=$PS0'\[\e[0 q\]' # reset

View File

@ -1,5 +1,6 @@
{
use platform
use re
use str
# 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-command = (conj $edit:after-command $mark-output-end~)
if (str:contains $E:GHOSTTY_SHELL_FEATURES "cursor") {
var cursor = "5" # blinking bar
if (has-value $features cursor:steady) {
set cursor = "6" # steady bar
}
if (str:contains $E:GHOSTTY_SHELL_FEATURES "cursor:") {
var match = (re:find 'cursor:(\d+)' $E:GHOSTTY_SHELL_FEATURES)
var cursor = $match[groups][1][text]
fn beam { printf "\e["$cursor" q" }
fn set-cursor { printf "\e["$cursor" 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 })
}
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"
end
if string match -q 'cursor*' -- $features
set -l cursor 5 # blinking bar
contains cursor:steady $features && set cursor 6 # steady bar
if string match -q 'cursor:*' -- $features
set -l cursor (string replace -r '.*cursor:(\d+).*' '$1' -- $GHOSTTY_SHELL_FEATURES)
# Change the cursor to a beam on prompt.
function __ghostty_set_cursor_beam --on-event fish_prompt -V cursor -d "Set cursor shape"
function __ghostty_set_cursor --on-event fish_prompt -V cursor -d "Set cursor shape"
if not functions -q fish_vi_cursor_handle
echo -en "\e[$cursor q"
end
@ -168,8 +166,8 @@ function __ghostty_setup --on-event fish_prompt -d "Setup ghostty integration"
set --global fish_handle_reflow 1
# Initial calls for first prompt
if string match -q 'cursor*' -- $features
__ghostty_set_cursor_beam
if string match -q 'cursor:*' -- $features
__ghostty_set_cursor
end
__ghostty_mark_prompt_start
__update_cwd_osc

View File

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

View File

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

View File

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