Merge remote-tracking branch 'upstream/main' into grapheme-width-changes

This commit is contained in:
Jacob Sandlund
2026-02-12 09:31:16 -05:00
68 changed files with 6983 additions and 5191 deletions

View File

@@ -3969,6 +3969,15 @@ pub fn mouseButtonCallback(
log.warn("error processing links err={}", .{err});
}
}
// Handle prompt clicking. If we released our mouse on a prompt
// and we support some kind of click events, then we need to
// move to it.
if (self.maybePromptClick()) |handled| {
if (handled) return true;
} else |err| {
log.warn("error processing prompt click err={}", .{err});
}
}
// Report mouse events if enabled
@@ -4010,25 +4019,6 @@ pub fn mouseButtonCallback(
}
}
// For left button click release we check if we are moving our cursor.
if (button == .left and
action == .release and
mods.alt)
click_move: {
self.renderer_state.mutex.lock();
defer self.renderer_state.mutex.unlock();
// If we have a selection then we do not do click to move because
// it means that we moved our cursor while pressing the mouse button.
if (self.io.terminal.screens.active.selection != null) break :click_move;
// Moving always resets the click count so that we don't highlight.
self.mouse.left_click_count = 0;
const pin = self.mouse.left_click_pin orelse break :click_move;
try self.clickMoveCursor(pin.*);
return true;
}
// For left button clicks we always record some information for
// selection/highlighting purposes.
if (button == .left and action == .press) click: {
@@ -4188,10 +4178,6 @@ pub fn mouseButtonCallback(
.y = pt_viewport.y,
},
}) orelse {
// Weird... our viewport x/y that we just converted isn't
// found in our pages. This is probably a bug but we don't
// want to crash in releases because its harmless. So, we
// only assert in debug mode.
if (comptime std.debug.runtime_safety) unreachable;
break :sel;
};
@@ -4278,58 +4264,118 @@ pub fn mouseButtonCallback(
return false;
}
/// Performs the "click-to-move" logic to move the cursor to the given
/// screen point if possible. This works by converting the path to the
/// given point into a series of arrow key inputs.
fn clickMoveCursor(self: *Surface, to: terminal.Pin) !void {
// If click-to-move is disabled then we're done.
if (!self.config.cursor_click_to_move) return;
fn maybePromptClick(self: *Surface) !bool {
self.renderer_state.mutex.lock();
defer self.renderer_state.mutex.unlock();
const t: *terminal.Terminal = self.renderer_state.terminal;
const screen: *terminal.Screen = t.screens.active;
const t = &self.io.terminal;
// If our screen doesn't handle any prompt clicks, then we never
// do anything.
if (screen.semantic_prompt.click == .none) return false;
// Click to move cursor only works on the primary screen where prompts
// exist. This means that alt screen multiplexers like tmux will not
// support this feature. It is just too messy.
if (t.screens.active_key != .primary) return;
// If our cursor isn't currently at a prompt then we don't handle
// prompt clicks because we can't move if we're not in a prompt!
if (!t.cursorIsAtPrompt()) return false;
// This flag is only set if we've seen at least one semantic prompt
// OSC sequence. If we've never seen that sequence, we can't possibly
// move the cursor so we can fast path out of here.
if (!t.screens.active.flags.semantic_content) return;
// If we have a selection currently, then releasing the mouse
// completes the selection and we don't do prompt moving. I don't
// love this logic, I think it should be generalized to "if the
// mouse release was on a different cell than the mouse press" but
// our mouse state at the time of writing this doesn't support that.
if (screen.selection != null) return false;
// Get our path
const from = t.screens.active.cursor.page_pin.*;
const path = t.screens.active.promptPath(from, to);
log.debug("click-to-move-cursor from={} to={} path={}", .{ from, to, path });
// If we aren't moving at all, fast path out of here.
if (path.x == 0 and path.y == 0) return;
// Convert our path to arrow key inputs. Yes, that is how this works.
// Yes, that is pretty sad. Yes, this could backfire in various ways.
// But its the best we can do.
// We do Y first because it prevents any weird wrap behavior.
if (path.y != 0) {
const arrow = if (path.y < 0) arrow: {
break :arrow if (t.modes.get(.cursor_keys)) "\x1bOA" else "\x1b[A";
} else arrow: {
break :arrow if (t.modes.get(.cursor_keys)) "\x1bOB" else "\x1b[B";
// Get the pin for our mouse click.
const pos = try self.rt_surface.getCursorPos();
const pos_vp = self.posToViewport(pos.x, pos.y);
const click_pin: terminal.Pin = pin: {
const pin = screen.pages.pin(.{
.viewport = .{
.x = pos_vp.x,
.y = pos_vp.y,
},
}) orelse {
// See mouseButtonCallback for explanation
if (comptime std.debug.runtime_safety) unreachable;
return false;
};
for (0..@abs(path.y)) |_| {
self.queueIo(.{ .write_stable = arrow }, .locked);
}
}
if (path.x != 0) {
const arrow = if (path.x < 0) arrow: {
break :arrow if (t.modes.get(.cursor_keys)) "\x1bOD" else "\x1b[D";
} else arrow: {
break :arrow if (t.modes.get(.cursor_keys)) "\x1bOC" else "\x1b[C";
break :pin pin;
};
// Get our cursor's most current prompt.
const prompt_pin: terminal.Pin = prompt_pin: {
var it = screen.cursor.page_pin.promptIterator(
.left_up,
null,
);
break :prompt_pin it.next() orelse {
// This shouldn't be possible because we asserted we're at
// a prompt above, so we MUST find some prompt in a left_up search.
log.warn("cursor is at prompt but no prompt found", .{});
if (comptime std.debug.runtime_safety) unreachable;
return false;
};
for (0..@abs(path.x)) |_| {
self.queueIo(.{ .write_stable = arrow }, .locked);
}
};
// If our mouse click is before the prompt, we don't move.
// We DO ALLOW clicks AFTER the prompt, specifically with Kitty's
// click_events=1 since we rely on the shell to validate out of
// bounds clicks. This matches Kitty's logic as best I can tell.
if (click_pin.before(prompt_pin)) return false;
// At this point we've established:
// - Screen supports prompt clicks
// - Cursor is at a prompt
// - Click is at or below our prompt
switch (screen.semantic_prompt.click) {
// Guarded at the start of this function
.none => unreachable,
.click_events => {
// For the event, we always send a left-click press event.
// This matches what Kitty sends.
var data: termio.Message.WriteReq.Small.Array = undefined;
const resp = try std.fmt.bufPrint(
&data,
"\x1B[<0;{d};{d}M",
.{ pos_vp.x + 1, pos_vp.y + 1 },
);
// Not that noisy since this only happens on prompt clicks.
log.debug(
"sending click_events=1 event=ESC{s}",
.{resp[1..]},
);
// Ask our IO thread to write the data
self.queueIo(.{ .write_small = .{
.data = data,
.len = @intCast(resp.len),
} }, .locked);
},
.cl => {
const left_arrow = if (t.modes.get(.cursor_keys)) "\x1bOD" else "\x1b[D";
const right_arrow = if (t.modes.get(.cursor_keys)) "\x1bOC" else "\x1b[C";
const move = screen.promptClickMove(click_pin);
for (0..move.left) |_| {
self.queueIo(
.{ .write_stable = left_arrow },
.locked,
);
}
for (0..move.right) |_| {
self.queueIo(
.{ .write_stable = right_arrow },
.locked,
);
}
},
}
return true;
}
const Link = struct {

View File

@@ -697,6 +697,13 @@ pub const Surface = extern struct {
/// Whether primary paste (middle-click paste) is enabled.
gtk_enable_primary_paste: bool = true,
/// How much pending horizontal scroll do we have?
pending_horizontal_scroll: f64 = 0.0,
/// Timer to reset the amount of horizontal scroll if the user
/// stops scrolling.
pending_horizontal_scroll_reset: ?c_uint = null,
pub var offset: c_int = 0;
};
@@ -1880,6 +1887,13 @@ pub const Surface = extern struct {
priv.idle_rechild = null;
}
if (priv.pending_horizontal_scroll_reset) |v| {
if (glib.Source.remove(v) == 0) {
log.warn("unable to remove pending horizontal scroll reset source", .{});
}
priv.pending_horizontal_scroll_reset = null;
}
// This works around a GTK double-free bug where if you bind
// to a top-level template child, it frees twice if the widget is
// also the root child of the template. By unsetting the child here,
@@ -2879,27 +2893,27 @@ pub const Surface = extern struct {
}
}
fn ecMouseScrollPrecisionBegin(
fn ecMouseScrollVerticalPrecisionBegin(
_: *gtk.EventControllerScroll,
self: *Self,
) callconv(.c) void {
self.private().precision_scroll = true;
}
fn ecMouseScrollPrecisionEnd(
fn ecMouseScrollVerticalPrecisionEnd(
_: *gtk.EventControllerScroll,
self: *Self,
) callconv(.c) void {
self.private().precision_scroll = false;
}
fn ecMouseScroll(
fn ecMouseScrollVertical(
_: *gtk.EventControllerScroll,
x: f64,
y: f64,
self: *Self,
) callconv(.c) c_int {
const priv = self.private();
const priv: *Private = self.private();
const surface = priv.core_surface orelse return 0;
// Multiply precision scrolls by 10 to get a better response from
@@ -2926,6 +2940,57 @@ pub const Surface = extern struct {
return 1;
}
fn ecMouseScrollHorizontal(
ec: *gtk.EventControllerScroll,
x: f64,
_: f64,
self: *Self,
) callconv(.c) c_int {
const priv: *Private = self.private();
switch (ec.getUnit()) {
.surface => {},
.wheel => return @intFromBool(false),
else => return @intFromBool(false),
}
priv.pending_horizontal_scroll += x;
if (@abs(priv.pending_horizontal_scroll) < 120) {
if (priv.pending_horizontal_scroll_reset) |v| {
_ = glib.Source.remove(v);
priv.pending_horizontal_scroll_reset = null;
}
priv.pending_horizontal_scroll_reset = glib.timeoutAdd(500, ecMouseScrollHorizontalReset, self);
return @intFromBool(true);
}
_ = self.as(gtk.Widget).activateAction(
if (priv.pending_horizontal_scroll < 0.0)
"tab.next-page"
else
"tab.previous-page",
null,
);
if (priv.pending_horizontal_scroll_reset) |v| {
_ = glib.Source.remove(v);
priv.pending_horizontal_scroll_reset = null;
}
priv.pending_horizontal_scroll = 0.0;
return @intFromBool(true);
}
fn ecMouseScrollHorizontalReset(ud: ?*anyopaque) callconv(.c) c_int {
const self: *Self = @ptrCast(@alignCast(ud orelse return @intFromBool(glib.SOURCE_REMOVE)));
const priv: *Private = self.private();
priv.pending_horizontal_scroll = 0.0;
priv.pending_horizontal_scroll_reset = null;
return @intFromBool(glib.SOURCE_REMOVE);
}
fn imPreeditStart(
_: *gtk.IMMulticontext,
self: *Self,
@@ -3464,9 +3529,10 @@ pub const Surface = extern struct {
class.bindTemplateCallback("mouse_up", &gcMouseUp);
class.bindTemplateCallback("mouse_motion", &ecMouseMotion);
class.bindTemplateCallback("mouse_leave", &ecMouseLeave);
class.bindTemplateCallback("scroll", &ecMouseScroll);
class.bindTemplateCallback("scroll_begin", &ecMouseScrollPrecisionBegin);
class.bindTemplateCallback("scroll_end", &ecMouseScrollPrecisionEnd);
class.bindTemplateCallback("scroll_vertical", &ecMouseScrollVertical);
class.bindTemplateCallback("scroll_vertical_begin", &ecMouseScrollVerticalPrecisionBegin);
class.bindTemplateCallback("scroll_vertical_end", &ecMouseScrollVerticalPrecisionEnd);
class.bindTemplateCallback("scroll_horizontal", &ecMouseScrollHorizontal);
class.bindTemplateCallback("drop", &dtDrop);
class.bindTemplateCallback("gl_realize", &glareaRealize);
class.bindTemplateCallback("gl_unrealize", &glareaUnrealize);

View File

@@ -202,6 +202,8 @@ pub const Tab = extern struct {
const actions = [_]ext.actions.Action(Self){
.init("close", actionClose, s_param_type),
.init("ring-bell", actionRingBell, null),
.init("next-page", actionNextPage, null),
.init("previous-page", actionPreviousPage, null),
};
_ = ext.actions.addAsGroup(Self, self, "tab", &actions);
@@ -235,12 +237,17 @@ pub const Tab = extern struct {
return tree.getNeedsConfirmQuit();
}
/// Get the tab page holding this tab, if any.
fn getTabPage(self: *Self) ?*adw.TabPage {
const tab_view = ext.getAncestor(
/// Get the tab view holding this tab, if any.
fn getTabView(self: *Self) ?*adw.TabView {
return ext.getAncestor(
adw.TabView,
self.as(gtk.Widget),
) orelse return null;
);
}
/// Get the tab page holding this tab, if any.
fn getTabPage(self: *Self) ?*adw.TabPage {
const tab_view = self.getTabView() orelse return null;
return tab_view.getPage(self.as(gtk.Widget));
}
@@ -325,11 +332,7 @@ pub const Tab = extern struct {
var str: ?[*:0]const u8 = null;
param.get("&s", &str);
const tab_view = ext.getAncestor(
adw.TabView,
self.as(gtk.Widget),
) orelse return;
const tab_view = self.getTabView() orelse return;
const page = tab_view.getPage(self.as(gtk.Widget));
const mode = std.meta.stringToEnum(
@@ -372,6 +375,26 @@ pub const Tab = extern struct {
page.setNeedsAttention(@intFromBool(true));
}
/// Select the next tab page.
fn actionNextPage(
_: *gio.SimpleAction,
_: ?*glib.Variant,
self: *Self,
) callconv(.c) void {
const tab_view = self.getTabView() orelse return;
_ = tab_view.selectNextPage();
}
/// Select the previous tab page.
fn actionPreviousPage(
_: *gio.SimpleAction,
_: ?*glib.Variant,
self: *Self,
) callconv(.c) void {
const tab_view = self.getTabView() orelse return;
_ = tab_view.selectPreviousPage();
}
fn closureComputedTitle(
_: *Self,
config_: ?*Config,

View File

@@ -53,10 +53,15 @@ Overlay terminal_page {
}
EventControllerScroll {
scroll => $scroll();
scroll-begin => $scroll_begin();
scroll-end => $scroll_end();
flags: both_axes;
scroll => $scroll_vertical();
scroll-begin => $scroll_vertical_begin();
scroll-end => $scroll_vertical_end();
flags: vertical;
}
EventControllerScroll {
scroll => $scroll_horizontal();
flags: horizontal;
}
EventControllerMotion {

View File

@@ -26,7 +26,7 @@ pub const regex =
"(?:" ++ url_schemes ++
\\)(?:
++ ipv6_url_pattern ++
\\|[\w\-.~:/?#@!$&*+,;=%]+(?:[\(\[]\w*[\)\]])?)+(?<![,.])|(?:\.\.\/|\.\/|\/)(?:(?=[\w\-.~:\/?#@!$&*+,;=%]*\.)[\w\-.~:\/?#@!$&*+,;=%]+(?: [\w\-.~:\/?#@!$&*+,;=%]*[\/.])*(?: +(?= *$))?|(?![\w\-.~:\/?#@!$&*+,;=%]*\.)[\w\-.~:\/?#@!$&*+,;=%]+(?: [\w\-.~:\/?#@!$&*+,;=%]+)*(?: +(?= *$))?)
\\|[\w\-.~:/?#@!$&*+,;=%]+(?:[\(\[]\w*[\)\]])?)+(?<![,.])|(?:\.\.\/|\.\/|(?<!\w)\/)(?:(?=[\w\-.~:\/?#@!$&*+,;=%]*\.)[\w\-.~:\/?#@!$&*+,;=%]+(?: [\w\-.~:\/?#@!$&*+,;=%]*[\/.])*(?: +(?= *$))?|(?![\w\-.~:\/?#@!$&*+,;=%]*\.)[\w\-.~:\/?#@!$&*+,;=%]+(?: [\w\-.~:\/?#@!$&*+,;=%]+)*(?: +(?= *$))?)|[\w][\w\-.]*\/(?=[\w\-.~:\/?#@!$&*+,;=%]*\.)[\w\-.~:\/?#@!$&*+,;=%]+(?: [\w\-.~:\/?#@!$&*+,;=%]*[\/.])*(?: +(?= *$))?
;
const url_schemes =
\\https?://|mailto:|ftp://|file:|ssh:|git://|ssh://|tel:|magnet:|ipfs://|ipns://|gemini://|gopher://|news:
@@ -270,6 +270,27 @@ test "url regex" {
.input = "/tmp/test folder/file.txt",
.expect = "/tmp/test folder/file.txt",
},
// Bare relative file paths (no ./ or ../ prefix)
.{
.input = "src/config/url.zig",
.expect = "src/config/url.zig",
},
.{
.input = "app/folder/file.rb:1",
.expect = "app/folder/file.rb:1",
},
.{
.input = "modified: src/config/url.zig",
.expect = "src/config/url.zig",
},
.{
.input = "lib/ghostty/terminal.zig:42:10",
.expect = "lib/ghostty/terminal.zig:42:10",
},
.{
.input = "some-pkg/src/file.txt more text",
.expect = "some-pkg/src/file.txt",
},
};
for (cases) |case| {
@@ -284,4 +305,17 @@ test "url regex" {
const match = case.input[@intCast(reg.starts()[0])..@intCast(reg.ends()[0])];
try testing.expectEqualStrings(case.expect, match);
}
// Bare relative paths without any dot should not match as file paths
const no_match_cases = [_][]const u8{
"input/output",
"foo/bar",
};
for (no_match_cases) |input| {
var result = re.search(input, .{});
if (result) |*reg| {
reg.deinit();
return error.TestUnexpectedResult;
} else |_| {}
}
}

View File

@@ -75,6 +75,11 @@ pub const Info = struct {
data.modify_other_keys_2,
);
if (cimgui.c.ImGui_CollapsingHeader(
"Semantic Prompt",
cimgui.c.ImGuiTreeNodeFlags_DefaultOpen,
)) semanticPromptTable(&screen.semantic_prompt);
if (cimgui.c.ImGui_CollapsingHeader(
"Kitty Graphics",
cimgui.c.ImGuiTreeNodeFlags_DefaultOpen,
@@ -355,15 +360,41 @@ pub fn internalStateTable(
cimgui.c.ImGui_Text("Viewport Location");
_ = cimgui.c.ImGui_TableSetColumnIndex(1);
cimgui.c.ImGui_Text("%s", @tagName(pages.viewport).ptr);
}
/// Render semantic prompt state table.
pub fn semanticPromptTable(
semantic_prompt: *const terminal.Screen.SemanticPrompt,
) void {
if (!cimgui.c.ImGui_BeginTable(
"##semantic_prompt",
2,
cimgui.c.ImGuiTableFlags_None,
)) return;
defer cimgui.c.ImGui_EndTable();
{
cimgui.c.ImGui_TableNextRow();
_ = cimgui.c.ImGui_TableSetColumnIndex(0);
cimgui.c.ImGui_Text("Semantic Content");
cimgui.c.ImGui_Text("Seen");
cimgui.c.ImGui_SameLine();
widgets.helpMarker("Whether semantic prompt markers (OSC 133) have been seen.");
widgets.helpMarker("Whether any semantic prompt markers (OSC 133) have been seen in this screen.");
_ = cimgui.c.ImGui_TableSetColumnIndex(1);
var value: bool = screen.flags.semantic_content;
_ = cimgui.c.ImGui_Checkbox("##semantic_content", &value);
var value: bool = semantic_prompt.seen;
_ = cimgui.c.ImGui_Checkbox("##seen", &value);
}
{
cimgui.c.ImGui_TableNextRow();
_ = cimgui.c.ImGui_TableSetColumnIndex(0);
cimgui.c.ImGui_Text("Click Handling");
cimgui.c.ImGui_SameLine();
widgets.helpMarker("How click events are handled in prompts. Set via 'cl' or 'click_events' options in OSC 133.");
_ = cimgui.c.ImGui_TableSetColumnIndex(1);
switch (semantic_prompt.click) {
.none => cimgui.c.ImGui_TextDisabled("(none)"),
.click_events => cimgui.c.ImGui_Text("click_events"),
.cl => |cl| cimgui.c.ImGui_Text("cl=%s", @tagName(cl).ptr),
}
}
}

View File

@@ -53,4 +53,5 @@ pub const locales = [_][:0]const u8{
"zh_TW.UTF-8",
"hr_HR.UTF-8",
"lt_LT.UTF-8",
"lv_LV.UTF-8",
};

View File

@@ -2206,10 +2206,35 @@ pub fn Renderer(comptime GraphicsAPI: type) type {
}
// If we had a previous overlay, clear it. Otherwise, init.
const overlay: *Overlay = if (self.overlay) |*v| overlay: {
v.reset();
break :overlay v;
} else overlay: {
const overlay: *Overlay = overlay: {
if (self.overlay) |*v| existing: {
// Verify that our overlay size matches our screen
// size as we know it now. If not, deinit and reinit.
// Note: these intCasts are always safe because z2d
// stores as i32 but we always init with a u32.
const width: u32 = @intCast(v.surface.getWidth());
const height: u32 = @intCast(v.surface.getHeight());
const term_size = self.size.terminal();
if (width != term_size.width or
height != term_size.height) break :existing;
// We also depend on cell size.
if (v.cell_size.width != self.size.cell.width or
v.cell_size.height != self.size.cell.height) break :existing;
// Everything matches, so we can just reset the surface
// and redraw.
v.reset();
break :overlay v;
}
// If we reached this point we want to reset our overlay.
if (self.overlay) |*v| {
v.deinit(alloc);
self.overlay = null;
}
assert(self.overlay == null);
const new: Overlay = try .init(alloc, self.size);
self.overlay = new;
break :overlay &self.overlay.?;

View File

@@ -44,7 +44,7 @@ Elvish, on startup, searches for paths defined in `XDG_DATA_DIRS`
variable for `./elvish/lib/*.elv` files and imports them. They are thus
made available for use as modules by way of `use <filename>`.
Ghostty launches Elvish, passing the environment with `XDG_DATA_DIRS`prepended
Ghostty launches Elvish, passing the environment with `XDG_DATA_DIRS` prepended
with `$GHOSTTY_RESOURCES_DIR/src/shell-integration`. It contains
`./elvish/lib/ghostty-integration.elv`. The user can then import it
by `use ghostty-integration` every time after shell startup or
@@ -57,7 +57,7 @@ of your `rc.elv` file:
```elvish
if (eq $E:TERM "xterm-ghostty") {
use ghostty-integration
try { use ghostty-integration } catch { }
}
```

View File

@@ -184,9 +184,6 @@ if [[ "$GHOSTTY_SHELL_FEATURES" == *ssh-* ]]; then
}
fi
# Import bash-preexec, safe to do multiple times
builtin source "$(dirname -- "${BASH_SOURCE[0]}")/bash-preexec.sh"
# This is set to 1 when we're executing a command so that we don't
# send prompt marks multiple times.
_ghostty_executing=""
@@ -201,7 +198,7 @@ function __ghostty_precmd() {
# Marks. We need to do fresh line (A) at the beginning of the prompt
# since if the cursor is not at the beginning of a line, the terminal
# will emit a newline.
PS1='\[\e]133;A;redraw=last\a\]'$PS1'\[\e]133;B\a\]'
PS1='\[\e]133;A;redraw=last;cl=line\a\]'$PS1'\[\e]133;B\a\]'
PS2='\[\e]133;A;k=s\a\]'$PS2'\[\e]133;B\a\]'
# Bash doesn't redraw the leading lines in a multiline prompt so
@@ -240,7 +237,7 @@ function __ghostty_precmd() {
fi
# Fresh line and start of prompt.
builtin printf "\e]133;A;redraw=last;aid=%s\a" "$BASHPID"
builtin printf "\e]133;A;redraw=last;cl=line;aid=%s\a" "$BASHPID"
_ghostty_executing=0
}
@@ -260,5 +257,48 @@ function __ghostty_preexec() {
_ghostty_executing=1
}
preexec_functions+=(__ghostty_preexec)
precmd_functions+=(__ghostty_precmd)
if (( BASH_VERSINFO[0] > 4 || (BASH_VERSINFO[0] == 4 && BASH_VERSINFO[1] >= 4) )); then
__ghostty_preexec_hook() {
builtin local cmd
cmd=$(LC_ALL=C HISTTIMEFORMAT='' builtin history 1)
cmd="${cmd#*[[:digit:]][* ] }" # remove leading history number
[[ -n "$cmd" ]] && __ghostty_preexec "$cmd"
}
# Use function substitution in 5.3+. Otherwise, use command substitution.
# Any output (including escape sequences) goes to the terminal.
if (( BASH_VERSINFO[0] > 5 || (BASH_VERSINFO[0] == 5 && BASH_VERSINFO[1] >= 3) )); then
# shellcheck disable=SC2016
builtin readonly __ghostty_ps0='${ __ghostty_preexec_hook; }'
else
# shellcheck disable=SC2016
builtin readonly __ghostty_ps0='$(__ghostty_preexec_hook >/dev/tty)'
fi
__ghostty_hook() {
builtin local ret=$?
__ghostty_precmd "$ret"
PS0=$__ghostty_ps0
}
# Append our hook to PROMPT_COMMAND, preserving its existing type.
if [[ ";${PROMPT_COMMAND[*]:-};" != *";__ghostty_hook;"* ]]; then
if [[ -z "${PROMPT_COMMAND[*]}" ]]; then
if (( BASH_VERSINFO[0] > 5 || (BASH_VERSINFO[0] == 5 && BASH_VERSINFO[1] >= 1) )); then
PROMPT_COMMAND=(__ghostty_hook)
else
# shellcheck disable=SC2178
PROMPT_COMMAND="__ghostty_hook"
fi
elif [[ $(builtin declare -p PROMPT_COMMAND 2>/dev/null) == "declare -a "* ]]; then
PROMPT_COMMAND+=(__ghostty_hook)
else
# shellcheck disable=SC2179
PROMPT_COMMAND+="; __ghostty_hook"
fi
fi
else
builtin source "$(dirname -- "${BASH_SOURCE[0]}")/bash-preexec.sh"
preexec_functions+=(__ghostty_preexec)
precmd_functions+=(__ghostty_precmd)
fi

View File

@@ -1,42 +1,12 @@
{
fn restore-xdg-dirs {
use str
var integration-dir = $E:GHOSTTY_SHELL_INTEGRATION_XDG_DIR
var xdg-dirs = [(str:split ':' $E:XDG_DATA_DIRS)]
var len = (count $xdg-dirs)
use platform
use str
var index = $nil
range $len | each {|dir-index|
if (eq $xdg-dirs[$dir-index] $integration-dir) {
set index = $dir-index
break
}
}
if (eq $nil $index) { return } # will appear as an error
if (== 0 $index) {
set xdg-dirs = $xdg-dirs[1..]
} elif (== (- $len 1) $index) {
set xdg-dirs = $xdg-dirs[0..(- $len 1)]
} else {
# no builtin function for this : )
set xdg-dirs = [ (take $index $xdg-dirs) (drop (+ 1 $index) $xdg-dirs) ]
}
if (== 0 (count $xdg-dirs)) {
unset-env XDG_DATA_DIRS
} else {
set-env XDG_DATA_DIRS (str:join ':' $xdg-dirs)
}
# Clean up XDG_DATA_DIRS by removing GHOSTTY_SHELL_INTEGRATION_XDG_DIR
if (and (has-env GHOSTTY_SHELL_INTEGRATION_XDG_DIR) (has-env XDG_DATA_DIRS)) {
set-env XDG_DATA_DIRS (str:replace $E:GHOSTTY_SHELL_INTEGRATION_XDG_DIR":" "" $E:XDG_DATA_DIRS)
unset-env GHOSTTY_SHELL_INTEGRATION_XDG_DIR
}
if (and (has-env GHOSTTY_SHELL_INTEGRATION_XDG_DIR) (has-env XDG_DATA_DIRS)) {
restore-xdg-dirs
}
}
{
use str
# List of enabled shell integration features
var features = [(str:split ',' $E:GHOSTTY_SHELL_FEATURES)]
@@ -77,24 +47,22 @@
printf "\e]133;D;"$exit-status"\a"
}
fn report-pwd {
use platform
printf "\e]7;kitty-shell-cwd://%s%s\a" (platform:hostname) $pwd
}
fn sudo-with-terminfo {|@args|
var sudoedit = $false
for arg $args {
use str
if (str:has-prefix $arg -) {
if (has-value [e -edit] $arg[1..]) {
if (str:has-prefix $arg --) {
if (eq $arg --edit) {
set sudoedit = $true
break
}
continue
} elif (str:has-prefix $arg -) {
if (str:contains (str:trim-prefix $arg -) e) {
set sudoedit = $true
break
}
} elif (not (str:contains $arg =)) {
break
}
if (not (has-value $arg =)) { break }
}
if (not $sudoedit) { set args = [ --preserve-env=TERMINFO $@args ] }
@@ -180,16 +148,12 @@
defer {
mark-prompt-start
report-pwd
}
set edit:before-readline = (conj $edit:before-readline $mark-prompt-start~)
set edit:after-readline = (conj $edit:after-readline $mark-output-start~)
set edit:after-command = (conj $edit:after-command $mark-output-end~)
if (has-value $features title) {
set after-chdir = (conj $after-chdir {|_| report-pwd })
}
if (has-value $features cursor) {
fn beam { printf "\e[5 q" }
fn block { printf "\e[0 q" }
@@ -207,4 +171,9 @@
if (and (str:contains $E:GHOSTTY_SHELL_FEATURES ssh-) (has-external ssh)) {
edit:add-var ssh~ $ssh-integration~
}
# Report changes to the current directory.
fn report-pwd { printf "\e]7;kitty-shell-cwd://%s%s\a" (platform:hostname) $pwd }
set after-chdir = (conj $after-chdir {|_| report-pwd })
report-pwd
}

View File

@@ -51,6 +51,27 @@ function __ghostty_setup --on-event fish_prompt -d "Setup ghostty integration"
set --local features (string split , $GHOSTTY_SHELL_FEATURES)
# Parse the fish version for feature detection.
# Default to 0.0 if version is unavailable or malformed.
set -l fish_major 0
set -l fish_minor 0
if set -q version[1]
set -l fish_ver (string match -r '(\d+)\.(\d+)' -- $version[1])
if set -q fish_ver[2]; and test -n "$fish_ver[2]"
set fish_major "$fish_ver[2]"
end
if set -q fish_ver[3]; and test -n "$fish_ver[3]"
set fish_minor "$fish_ver[3]"
end
end
# Our OSC133A (prompt start) sequence. If we're using Fish >= 4.1
# then it supports click_events so we enable that.
set -g __ghostty_prompt_start_mark "\e]133;A\a"
if test "$fish_major" -gt 4; or test "$fish_major" -eq 4 -a "$fish_minor" -ge 1
set -g __ghostty_prompt_start_mark "\e]133;A;click_events=1\a"
end
if contains cursor $features
# Change the cursor to a beam on prompt.
function __ghostty_set_cursor_beam --on-event fish_prompt -d "Set cursor shape"
@@ -72,14 +93,14 @@ function __ghostty_setup --on-event fish_prompt -d "Setup ghostty integration"
# When using sudo shell integration feature, ensure $TERMINFO is set
# and `sudo` is not already a function or alias
if contains sudo $features; and test -n "$TERMINFO"; and test "file" = (type -t sudo 2> /dev/null; or echo "x")
if contains sudo $features; and test -n "$TERMINFO"; and test file = (type -t sudo 2> /dev/null; or echo "x")
# Wrap `sudo` command to ensure Ghostty terminfo is preserved
function sudo -d "Wrap sudo to preserve terminfo"
set --function sudo_has_sudoedit_flags "no"
set --function sudo_has_sudoedit_flags no
for arg in $argv
# Check if argument is '-e' or '--edit' (sudoedit flags)
if string match -q -- "-e" "$arg"; or string match -q -- "--edit" "$arg"
set --function sudo_has_sudoedit_flags "yes"
if string match -q -- -e "$arg"; or string match -q -- --edit "$arg"
set --function sudo_has_sudoedit_flags yes
break
end
# Check if argument is neither an option nor a key-value pair
@@ -87,7 +108,7 @@ function __ghostty_setup --on-event fish_prompt -d "Setup ghostty integration"
break
end
end
if test "$sudo_has_sudoedit_flags" = "yes"
if test "$sudo_has_sudoedit_flags" = yes
command sudo $argv
else
command sudo --preserve-env=TERMINFO $argv
@@ -100,7 +121,7 @@ function __ghostty_setup --on-event fish_prompt -d "Setup ghostty integration"
if contains ssh-env $features; or contains ssh-terminfo $features
function ssh --wraps=ssh --description "SSH wrapper with Ghostty integration"
set -l features (string split ',' -- "$GHOSTTY_SHELL_FEATURES")
set -l ssh_term "xterm-256color"
set -l ssh_term xterm-256color
set -l ssh_opts
# Configure environment variables for remote session
@@ -134,7 +155,7 @@ function __ghostty_setup --on-event fish_prompt -d "Setup ghostty integration"
# Check if terminfo is already cached
if test -x "$GHOSTTY_BIN_DIR/ghostty"; and "$GHOSTTY_BIN_DIR/ghostty" +ssh-cache --host="$ssh_target" >/dev/null 2>&1
set ssh_term "xterm-ghostty"
set ssh_term xterm-ghostty
else if command -q infocmp
set -l ssh_terminfo
set -l ssh_cpath_dir
@@ -154,7 +175,7 @@ function __ghostty_setup --on-event fish_prompt -d "Setup ghostty integration"
mkdir -p ~/.terminfo 2>/dev/null && tic -x - 2>/dev/null && exit 0
exit 1
' 2>/dev/null
set ssh_term "xterm-ghostty"
set ssh_term xterm-ghostty
set -a ssh_opts -o "ControlPath=$ssh_cpath"
# Cache successful installation
@@ -179,14 +200,14 @@ function __ghostty_setup --on-event fish_prompt -d "Setup ghostty integration"
end
# Setup prompt marking
function __ghostty_mark_prompt_start --on-event fish_prompt --on-event fish_cancel --on-event fish_posterror
function __ghostty_mark_prompt_start --on-event fish_prompt --on-event fish_posterror
# If we never got the output end event, then we need to send it now.
if test "$__ghostty_prompt_state" != prompt-start
echo -en "\e]133;D\a"
end
set --global __ghostty_prompt_state prompt-start
echo -en "\e]133;A\a"
echo -en $__ghostty_prompt_start_mark
end
function __ghostty_mark_output_start --on-event fish_preexec

View File

@@ -4,106 +4,83 @@ export module ghostty {
$feature in ($env.GHOSTTY_SHELL_FEATURES | default "" | split row ',')
}
# Enables automatic terminfo installation on remote hosts.
# Attempts to install Ghostty's terminfo entry using infocmp and tic when
# connecting to hosts that lack it.
# Requires infocmp to be available locally and tic to be available on remote hosts.
# Caches installations to avoid repeat installations.
def set_ssh_terminfo [
ssh_opts: list<string>
ssh_args: list<string>
]: [nothing -> record<ssh_term: string, ssh_opts: list<string>>] {
let ssh_cfg = ^ssh -G ...($ssh_args)
| lines
| parse "{key} {value}"
| where key in ["user" "hostname"]
| select key value
| transpose -rd
| default {user: $env.USER hostname: "localhost"}
let ssh_id = $"($ssh_cfg.user)@($ssh_cfg.hostname)"
let ghostty_bin = $env.GHOSTTY_BIN_DIR | path join "ghostty"
let is_cached = (
^$ghostty_bin ...(["+ssh-cache" $"--host=($ssh_id)"])
| complete
| $in.exit_code == 0
)
if not $is_cached {
let terminfo_data = try { ^infocmp -0 -x xterm-ghostty } catch {
print "Warning: Could not generate terminfo data."
return {ssh_term: "xterm-256color" ssh_opts: $ssh_opts}
}
print $"Setting up xterm-ghostty terminfo on ($ssh_cfg.hostname)..."
let ctrl_path = (
mktemp -td $"ghostty-ssh-($ssh_cfg.user).XXXXXX"
| path join "socket"
)
let master_parts = $ssh_opts ++ ["-o" "ControlMaster=yes" "-o" $"ControlPath=($ctrl_path)" "-o" "ControlPersist=60s"] ++ $ssh_args
($terminfo_data) | ^ssh ...(
$master_parts ++
[
'
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'
]
)
| complete
| if $in.exit_code != 0 {
print "Warning: Failed to install terminfo."
return {ssh_term: "xterm-256color" ssh_opts: $ssh_opts}
}
^$ghostty_bin ...(["+ssh-cache" $"--add=($ssh_id)"]) o+e>| ignore
return {ssh_term: "xterm-ghostty" ssh_opts: ($ssh_opts ++ ["-o" $"ControlPath=($ctrl_path)"])}
}
return {ssh_term: "xterm-ghostty" ssh_opts: $ssh_opts}
}
# Wrap `ssh` with Ghostty TERMINFO support
export def --wrapped ssh [...ssh_args: string]: any -> any {
if ($ssh_args | is-empty) {
return (^ssh)
}
# `ssh-env` enables SSH environment variable compatibility.
# Converts TERM from xterm-ghostty to xterm-256color
# and propagates COLORTERM, TERM_PROGRAM, and TERM_PROGRAM_VERSION
# Check your sshd_config on remote host to see if these variables are accepted
let base_ssh_opts = if (has_feature "ssh-env") {
["-o" "SetEnv COLORTERM=truecolor" "-o" "SendEnv TERM_PROGRAM TERM_PROGRAM_VERSION"]
} else {
[]
}
let base_ssh_term = if (has_feature "ssh-env") {
"xterm-256color"
} else {
($env.TERM? | default "")
export def --wrapped ssh [...args] {
mut ssh_env = {}
mut ssh_opts = []
# `ssh-env`: use xterm-256color and propagate COLORTERM/TERM_PROGRAM vars
if (has_feature "ssh-env") {
$ssh_env.TERM = "xterm-256color"
$ssh_opts = [
"-o" "SetEnv COLORTERM=truecolor"
"-o" "SendEnv TERM_PROGRAM TERM_PROGRAM_VERSION"
]
}
let session = if (has_feature "ssh-terminfo") {
set_ssh_terminfo $base_ssh_opts $ssh_args
} else {
{ssh_term: $base_ssh_term ssh_opts: $base_ssh_opts}
# `ssh-terminfo`: auto-install xterm-ghostty terminfo on remote hosts
if (has_feature "ssh-terminfo") {
let ghostty = ($env.GHOSTTY_BIN_DIR? | default "") | path join "ghostty"
let ssh_cfg = ^ssh -G ...$args
| lines
| parse "{key} {value}"
| where key in ["user" "hostname"]
| select key value
| transpose -rd
| default {user: $env.USER hostname: "localhost"}
let ssh_id = $"($ssh_cfg.user)@($ssh_cfg.hostname)"
if (^$ghostty "+ssh-cache" $"--host=($ssh_id)" | complete | $in.exit_code == 0) {
$ssh_env.TERM = "xterm-ghostty"
} else {
$ssh_env.TERM = "xterm-256color"
let terminfo = try {
^infocmp -0 -x xterm-ghostty
} catch {
print -e "infocmp failed, using xterm-256color"
}
if ($terminfo | is-not-empty) {
print $"Setting up xterm-ghostty terminfo on ($ssh_cfg.hostname)..."
let ctrl_path = (
mktemp -td $"ghostty-ssh-($ssh_cfg.user).XXXXXX"
| path join "socket"
)
let remote_args = $ssh_opts ++ [
"-o" "ControlMaster=yes"
"-o" $"ControlPath=($ctrl_path)"
"-o" "ControlPersist=60s"
] ++ $args
$terminfo | ^ssh ...$remote_args '
infocmp xterm-ghostty >/dev/null 2>&1 && exit 0
command -v tic >/dev/null 2>&1 || exit 1
mkdir -p ~/.terminfo 2>/dev/null && tic -x - 2>/dev/null && exit 0
exit 1'
| complete
| if $in.exit_code == 0 {
^$ghostty "+ssh-cache" $"--add=($ssh_id)" e>| print -e
$ssh_env.TERM = "xterm-ghostty"
$ssh_opts = ($ssh_opts ++ ["-o" $"ControlPath=($ctrl_path)"])
} else {
print -e "terminfo install failed, using xterm-256color"
}
}
}
}
with-env {TERM: $session.ssh_term} {
^ssh ...($session.ssh_opts ++ $ssh_args)
let ssh_args = $ssh_opts ++ $args
with-env $ssh_env {
^ssh ...$ssh_args
}
}
# Wrap `sudo` to preserve Ghostty's TERMINFO environment variable
export def --wrapped sudo [
...args # Arguments to pass to `sudo`
] {
export def --wrapped sudo [...args] {
mut sudo_args = $args
if (has_feature "sudo") {

View File

@@ -121,7 +121,7 @@ _ghostty_deferred_init() {
fi
fi
builtin local mark1=$'%{\e]133;A\a%}'
builtin local mark1=$'%{\e]133;A;cl=line\a%}'
if [[ -o prompt_percent ]]; then
builtin typeset -g precmd_functions
if [[ ${precmd_functions[-1]} == _ghostty_precmd ]]; then

File diff suppressed because it is too large Load Diff

View File

@@ -1154,9 +1154,11 @@ pub fn semanticPrompt(
// "First do a fresh-line."
try self.semanticPromptFreshLine();
const screen: *Screen = self.screens.active;
// "Subsequent text (until a OSC "133;B" or OSC "133;I" command)
// is a prompt string (as if followed by OSC 133;P;k=i\007)."
self.screens.active.cursorSetSemanticContent(.{
screen.cursorSetSemanticContent(.{
.prompt = cmd.readOption(.prompt_kind) orelse .initial,
});
@@ -1167,17 +1169,41 @@ pub fn semanticPrompt(
self.flags.shell_redraws_prompt = v;
}
click: {
// Handle click_events as a priority over cl. click_events
// is another Kitty-specific extension that converts clicks
// within a prompt area to SGR mouse events and defers to the
// shell to handle them.
if (cmd.readOption(.click_events)) |v| {
if (v) {
screen.semantic_prompt.click = .click_events;
break :click;
}
}
// If click_events was not set or disabled, fallback to `cl`.
if (cmd.readOption(.cl)) |v| {
screen.semantic_prompt.click = .{ .cl = v };
}
}
// The "aid" and "cl" options are also valid for this
// command but we don't yet handle these in any meaningful way.
},
.new_command => {
// Spec:
// Same as OSC "133;A" but may first implicitly terminate a
// previous command: if the options specify an aid and there
// is an active (open) command with matching aid, finish the
// innermost such command (as well as any other commands
// nested more deeply). If no aid is specified, treat as an
// aid whose value is the empty string.
// Ghostty:
// We don't currently do explicit command tracking in any way
// so there is no need to terminate prior commands. We just
// perform the `A` action.
try self.semanticPrompt(.{
.action = .fresh_line_new_prompt,
.options_unvalidated = cmd.options_unvalidated,
@@ -11963,6 +11989,109 @@ test "Terminal: multiple newlines in prompt mode marks all rows" {
}
}
test "Terminal: OSC133A click_events=1 sets click to click_events" {
const alloc = testing.allocator;
var t = try init(alloc, .{ .cols = 10, .rows = 5 });
defer t.deinit(alloc);
// Verify default state is none
try testing.expectEqual(.none, t.screens.active.semantic_prompt.click);
// OSC 133;A with click_events=1
try t.semanticPrompt(.{
.action = .fresh_line_new_prompt,
.options_unvalidated = "click_events=1",
});
try testing.expectEqual(.click_events, t.screens.active.semantic_prompt.click);
}
test "Terminal: OSC133A click_events=0 does not set click_events" {
const alloc = testing.allocator;
var t = try init(alloc, .{ .cols = 10, .rows = 5 });
defer t.deinit(alloc);
// OSC 133;A with click_events=0
try t.semanticPrompt(.{
.action = .fresh_line_new_prompt,
.options_unvalidated = "click_events=0",
});
// Should remain none since click_events=0 doesn't activate anything
try testing.expectEqual(.none, t.screens.active.semantic_prompt.click);
}
test "Terminal: OSC133A cl option sets click to cl value" {
const alloc = testing.allocator;
var t = try init(alloc, .{ .cols = 10, .rows = 5 });
defer t.deinit(alloc);
// OSC 133;A with cl=m (multiple)
try t.semanticPrompt(.{
.action = .fresh_line_new_prompt,
.options_unvalidated = "cl=m",
});
try testing.expectEqual(Screen.SemanticPrompt.SemanticClick{ .cl = .multiple }, t.screens.active.semantic_prompt.click);
}
test "Terminal: OSC133A cl=line sets click to line" {
const alloc = testing.allocator;
var t = try init(alloc, .{ .cols = 10, .rows = 5 });
defer t.deinit(alloc);
try t.semanticPrompt(.{
.action = .fresh_line_new_prompt,
.options_unvalidated = "cl=line",
});
try testing.expectEqual(Screen.SemanticPrompt.SemanticClick{ .cl = .line }, t.screens.active.semantic_prompt.click);
}
test "Terminal: OSC133A click_events=1 takes priority over cl" {
const alloc = testing.allocator;
var t = try init(alloc, .{ .cols = 10, .rows = 5 });
defer t.deinit(alloc);
// OSC 133;A with both click_events=1 and cl=m
try t.semanticPrompt(.{
.action = .fresh_line_new_prompt,
.options_unvalidated = "click_events=1;cl=m",
});
// click_events should take priority
try testing.expectEqual(.click_events, t.screens.active.semantic_prompt.click);
}
test "Terminal: OSC133A click_events=0 falls back to cl" {
const alloc = testing.allocator;
var t = try init(alloc, .{ .cols = 10, .rows = 5 });
defer t.deinit(alloc);
// OSC 133;A with click_events=0 and cl=v
try t.semanticPrompt(.{
.action = .fresh_line_new_prompt,
.options_unvalidated = "click_events=0;cl=v",
});
// Should fall back to cl since click_events is disabled
try testing.expectEqual(Screen.SemanticPrompt.SemanticClick{ .cl = .conservative_vertical }, t.screens.active.semantic_prompt.click);
}
test "Terminal: OSC133A no click options leaves click as none" {
const alloc = testing.allocator;
var t = try init(alloc, .{ .cols = 10, .rows = 5 });
defer t.deinit(alloc);
// OSC 133;A with no click-related options
try t.semanticPrompt(.{
.action = .fresh_line_new_prompt,
.options_unvalidated = "aid=123",
});
try testing.expectEqual(.none, t.screens.active.semantic_prompt.click);
}
test "Terminal: cursorIsAtPrompt" {
const alloc = testing.allocator;
var t = try init(alloc, .{ .cols = 10, .rows = 3 });

View File

@@ -165,7 +165,7 @@ pub const Option = enum {
return switch (self) {
.aid => value,
.cl => std.meta.stringToEnum(Click, value),
.cl => .init(value),
.prompt_kind => if (value.len == 1) PromptKind.init(value[0]) else null,
.err => value,
.redraw => if (std.mem.eql(u8, value, "0"))
@@ -191,11 +191,43 @@ pub const Option = enum {
}
};
/// The `cl` option specifies what kind of cursor key sequences are handled
/// by the application for click-to-move-cursor functionality.
pub const Click = enum {
/// Value: "line". Allows motion within a single input line using standard
/// left/right arrow escape sequences. Only a single left/right sequence
/// should be emitted for double-width characters.
line,
/// Value: "m". Allows movement between different lines in the same group,
/// but only using left/right arrow escape sequences.
multiple,
/// Value: "v". Like `multiple` but cursor up/down should be used. The
/// terminal should be conservative when moving between lines: move the
/// cursor left to the start of line, emit the needed up/down sequences,
/// then move the cursor right to the clicked destination.
conservative_vertical,
/// Value: "w". Like `conservative_vertical` but specifies that there are
/// no spurious spaces at the end of the line, and the application editor
/// handles "smart vertical movement" (moving 2 lines up from position 20,
/// where the intermediate line is 15 chars wide and the destination is
/// 18 chars wide, ends at position 18).
smart_vertical,
pub fn init(value: []const u8) ?Click {
return if (value.len == 1) switch (value[0]) {
'm' => .multiple,
'v' => .conservative_vertical,
'w' => .smart_vertical,
else => null,
} else if (std.mem.eql(
u8,
value,
"line",
)) .line else null;
}
};
pub const PromptKind = enum {
@@ -447,12 +479,12 @@ test "OSC 133: fresh_line_new_prompt with cl=line" {
try testing.expect(cmd.semantic_prompt.readOption(.cl) == .line);
}
test "OSC 133: fresh_line_new_prompt with cl=multiple" {
test "OSC 133: fresh_line_new_prompt with cl=m" {
const testing = std.testing;
var p: Parser = .init(null);
const input = "133;A;cl=multiple";
const input = "133;A;cl=m";
for (input) |ch| p.next(ch);
const cmd = p.end(null).?.*;
@@ -874,9 +906,9 @@ test "Option.read aid" {
test "Option.read cl" {
const testing = std.testing;
try testing.expect(Option.cl.read("cl=line").? == .line);
try testing.expect(Option.cl.read("cl=multiple").? == .multiple);
try testing.expect(Option.cl.read("cl=conservative_vertical").? == .conservative_vertical);
try testing.expect(Option.cl.read("cl=smart_vertical").? == .smart_vertical);
try testing.expect(Option.cl.read("cl=m").? == .multiple);
try testing.expect(Option.cl.read("cl=v").? == .conservative_vertical);
try testing.expect(Option.cl.read("cl=w").? == .smart_vertical);
try testing.expect(Option.cl.read("cl=invalid") == null);
try testing.expect(Option.cl.read("aid=foo") == null);
}