Support OSC133 click_events Kitty extension (supported by Fish) (#10536)

This adds support for the `OSC 133 A click_events=1` extension
introduced by Kitty and supported by Fish.[^1]

**What this means:** If the shell advertises `click_events=1` support,
Ghostty will _unconditionally_ (no modifier required) send mouse events
to the shell for clicks on a prompt line, delegating to the supporting
shell to move the cursor as needed. For Fish 4.1+ this means that
clicking on the prompt line moves the cursor (see demo video below).

This PR also contains:

* A minor fix in `cl` parsing but we don't yet implement the logic there
* Updated inspector to show the semantic prompt click mode

## Demo


https://github.com/user-attachments/assets/03ef8975-7ad9-441f-aaa2-9d0eb5c5e36d

## Implementation Details

`click_events` is wildly underspecified, so here are the details the
best I understand them. This itself is not a specification (I omit
details) but adds some more context to it.

The `click_events=1` option can be specified with `OSC 133 A` (Ghostty
also allows it on OSC 133 N). When that is specified, it flags for all
future prompts that the screen supports click events for semantic
prompts. If both `click_events` and `cl` are specified, `click_events`
takes priority if true. If `click_events=0` (disable), then any set `cl`
will take priority.

When a mouse click comes in, we check for the following conditions:

1. The screen supports click events
2. The screen cursor is currently at a prompt
3. The mouse click was at or below the starting prompt line of the
current prompt

If those are met, we encode an SGR mouse event with: left button, press,
coordinates of click. It is up to the shell after that to handle it. Out
of prompt bounds SGR events are possible (specifically below). The shell
should robustly handle this.

[^1]: I don't know any other terminal or shell that supports it at the
moment.
This commit is contained in:
Mitchell Hashimoto
2026-02-02 10:57:30 -08:00
committed by GitHub
6 changed files with 366 additions and 33 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
@@ -4188,10 +4197,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,6 +4283,106 @@ pub fn mouseButtonCallback(
return false;
}
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;
// If our screen doesn't handle any prompt clicks, then we never
// do anything.
if (screen.semantic_prompt.click == .none) return false;
// 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;
// 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 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;
};
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;
};
};
// 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 => |cl| {
// TODO: Handle these
_ = cl;
},
}
return true;
}
/// 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.
@@ -4295,7 +4400,7 @@ fn clickMoveCursor(self: *Surface, to: terminal.Pin) !void {
// 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 (!t.screens.active.semantic_prompt.seen) return;
// Get our path
const from = t.screens.active.cursor.page_pin.*;

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

@@ -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
@@ -186,7 +207,7 @@ function __ghostty_setup --on-event fish_prompt -d "Setup ghostty integration"
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

@@ -74,12 +74,12 @@ kitty_images: if (build_options.kitty_graphics)
else
struct {} = .{},
/// Semantic prompt (OSC133) state.
semantic_prompt: SemanticPrompt = .disabled,
/// Dirty flags for the renderer.
dirty: Dirty = .{},
/// Packed flags for the screen, internal state.
flags: Flags = .{},
/// See Terminal.Dirty. This behaves the same way.
pub const Dirty = packed struct {
/// Set when the selection is set or unset, regardless of if the
@@ -91,15 +91,30 @@ pub const Dirty = packed struct {
hyperlink_hover: bool = false,
};
/// A set of internal state that we pack for memory size.
pub const Flags = packed struct {
pub const SemanticPrompt = struct {
/// This is flipped to true when any sort of semantic content is
/// seen. In particular, this is set to true only when a `prompt` type
/// is ever set on our cursor.
///
/// This is used to optimize away semantic content operations if we know
/// we've never seen them.
semantic_content: bool = false,
seen: bool,
/// This is set on any `cl` or `click_events` option set on the
/// most recent OSC 133 commands to specify how click handling in a
/// prompt is handling.
click: SemanticClick,
pub const disabled: SemanticPrompt = .{
.seen = false,
.click = .none,
};
pub const SemanticClick = union(enum) {
none,
click_events,
cl: osc.semantic_prompt.Click,
};
};
/// The cursor position and style.
@@ -378,7 +393,7 @@ pub fn reset(self: *Screen) void {
self.charset = .{};
self.kitty_keyboard = .{};
self.protected_mode = .off;
self.flags = .{};
self.semantic_prompt = .disabled;
self.clearSelection();
}
@@ -2362,7 +2377,7 @@ pub fn cursorSetSemanticContent(self: *Screen, t: union(enum) {
},
.prompt => |kind| {
self.flags.semantic_content = true;
self.semantic_prompt.seen = true;
cursor.semantic_content = .prompt;
cursor.semantic_content_clear_eol = false;
cursor.page_row.semantic_prompt = switch (kind) {

View File

@@ -1087,9 +1087,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,
});
@@ -1100,17 +1102,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,
@@ -11629,6 +11655,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);
}