lib-vt: add pwd_changed callback for OSC 7/9/1337

Previously the libghostty-vt stream handler dropped .report_pwd as a
no-op, so embedders never saw shell-reported cwd changes and the
terminal's pwd field was never populated from escape sequences.

Wire the action to setPwd and expose a pwd_changed callback analogous
to title_changed via GHOSTTY_TERMINAL_OPT_PWD_CHANGED. The payload is
passed through unparsed; embedders read it with ghostty_terminal_get
and decode any URI scheme themselves.
This commit is contained in:
Zongyuan Li
2026-06-05 16:36:06 +08:00
parent 7092b39445
commit 002fd41429
3 changed files with 141 additions and 1 deletions

View File

@@ -76,6 +76,7 @@ extern "C" {
* | `GHOSTTY_TERMINAL_OPT_WRITE_PTY` | `GhosttyTerminalWritePtyFn` | Query responses written back to the pty |
* | `GHOSTTY_TERMINAL_OPT_BELL` | `GhosttyTerminalBellFn` | BEL character (0x07) |
* | `GHOSTTY_TERMINAL_OPT_TITLE_CHANGED` | `GhosttyTerminalTitleChangedFn` | Title change via OSC 0 / OSC 2 |
* | `GHOSTTY_TERMINAL_OPT_PWD_CHANGED` | `GhosttyTerminalPwdChangedFn` | Pwd change via OSC 7 / OSC 9 / OSC 1337 |
* | `GHOSTTY_TERMINAL_OPT_ENQUIRY` | `GhosttyTerminalEnquiryFn` | ENQ character (0x05) |
* | `GHOSTTY_TERMINAL_OPT_XTVERSION` | `GhosttyTerminalXtversionFn` | XTVERSION query (CSI > q) |
* | `GHOSTTY_TERMINAL_OPT_SIZE` | `GhosttyTerminalSizeFn` | XTWINOPS size query (CSI 14/16/18 t) |
@@ -372,6 +373,31 @@ typedef bool (*GhosttyTerminalSizeFn)(GhosttyTerminal terminal,
typedef void (*GhosttyTerminalTitleChangedFn)(GhosttyTerminal terminal,
void* userdata);
/**
* Callback function type for pwd_changed.
*
* Called when the terminal pwd (current working directory) changes via
* escape sequences: OSC 7 (file:// URI), OSC 9 (ConEmu CurrentDir), or
* OSC 1337 CurrentDir (iTerm2). Use ghostty_terminal_get() with
* GHOSTTY_TERMINAL_DATA_PWD inside the callback to read the new value.
*
* The terminal stores whatever bytes the shell emitted, without parsing.
* That means for OSC 7 the value is the raw URI (typically file://...);
* for OSC 9/OSC 1337 it is typically a bare path. The embedder is
* responsible for decoding any URI scheme or host if it cares about them.
*
* The callback also fires when the shell clears the pwd (e.g. an empty
* OSC 7). In that case GHOSTTY_TERMINAL_DATA_PWD returns a zero-length
* string.
*
* @param terminal The terminal handle
* @param userdata The userdata pointer set via GHOSTTY_TERMINAL_OPT_USERDATA
*
* @ingroup terminal
*/
typedef void (*GhosttyTerminalPwdChangedFn)(GhosttyTerminal terminal,
void* userdata);
/**
* Callback function type for write_pty.
*
@@ -659,6 +685,14 @@ typedef enum GHOSTTY_ENUM_TYPED {
*/
GHOSTTY_TERMINAL_OPT_GLYPH_PROTOCOL = 24,
/**
* Callback invoked when the terminal pwd changes via escape
* sequences (OSC 7, OSC 9, or OSC 1337 CurrentDir). Set to NULL
* to ignore pwd change events.
*
* Input type: GhosttyTerminalPwdChangedFn
*/
GHOSTTY_TERMINAL_OPT_PWD_CHANGED = 25,
GHOSTTY_TERMINAL_OPT_MAX_VALUE = GHOSTTY_ENUM_MAX_VALUE,
} GhosttyTerminalOption;

View File

@@ -52,6 +52,7 @@ const Effects = struct {
enquiry: ?EnquiryFn = null,
xtversion: ?XtversionFn = null,
title_changed: ?TitleChangedFn = null,
pwd_changed: ?PwdChangedFn = null,
size_cb: ?SizeFn = null,
/// Scratch buffer for DA1 feature codes. The device attributes
@@ -86,6 +87,9 @@ const Effects = struct {
/// C function pointer type for the title_changed callback.
pub const TitleChangedFn = *const fn (Terminal, ?*anyopaque) callconv(lib.calling_conv) void;
/// C function pointer type for the pwd_changed callback.
pub const PwdChangedFn = *const fn (Terminal, ?*anyopaque) callconv(lib.calling_conv) void;
/// C function pointer type for the size callback.
/// Returns true and fills out_size if size is available,
/// or returns false to silently ignore the query.
@@ -199,6 +203,13 @@ const Effects = struct {
func(@ptrCast(wrapper), wrapper.effects.userdata);
}
fn pwdChangedTrampoline(handler: *Handler) void {
const stream_ptr: *Stream = @fieldParentPtr("handler", handler);
const wrapper: *TerminalWrapper = @fieldParentPtr("stream", stream_ptr);
const func = wrapper.effects.pwd_changed orelse return;
func(@ptrCast(wrapper), wrapper.effects.userdata);
}
fn sizeTrampoline(handler: *Handler) ?size_report.Size {
const stream_ptr: *Stream = @fieldParentPtr("handler", handler);
const wrapper: *TerminalWrapper = @fieldParentPtr("stream", stream_ptr);
@@ -283,6 +294,7 @@ fn new_(
.enquiry = &Effects.enquiryTrampoline,
.xtversion = &Effects.xtversionTrampoline,
.title_changed = &Effects.titleChangedTrampoline,
.pwd_changed = &Effects.pwdChangedTrampoline,
.size = &Effects.sizeTrampoline,
};
@@ -330,6 +342,7 @@ pub const Option = enum(c_int) {
default_cursor_style = 22,
default_cursor_blink = 23,
glyph_protocol = 24,
pwd_changed = 25,
/// Input type expected for setting the option.
pub fn InType(comptime self: Option) type {
@@ -342,6 +355,7 @@ pub const Option = enum(c_int) {
.enquiry => ?Effects.EnquiryFn,
.xtversion => ?Effects.XtversionFn,
.title_changed => ?Effects.TitleChangedFn,
.pwd_changed => ?Effects.PwdChangedFn,
.size_cb => ?Effects.SizeFn,
.title, .pwd => ?*const lib.String,
.color_foreground, .color_background, .color_cursor => ?*const color.RGB.C,
@@ -397,6 +411,7 @@ fn setTyped(
.enquiry => wrapper.effects.enquiry = value,
.xtversion => wrapper.effects.xtversion = value,
.title_changed => wrapper.effects.title_changed = value,
.pwd_changed => wrapper.effects.pwd_changed = value,
.size_cb => wrapper.effects.size_cb = value,
.title => {
const str = if (value) |v| v.ptr[0..v.len] else "";
@@ -2363,6 +2378,68 @@ test "title_changed without callback is silent" {
vt_write(t, "\x1B]2;Hello\x1B\\", 10);
}
test "set pwd_changed callback" {
var t: Terminal = null;
try testing.expectEqual(Result.success, new(
&lib.alloc.test_allocator,
&t,
.{
.cols = 80,
.rows = 24,
.max_scrollback = 0,
},
));
defer free(t);
const S = struct {
var pwd_count: usize = 0;
var last_userdata: ?*anyopaque = null;
fn pwdChanged(_: Terminal, ud: ?*anyopaque) callconv(lib.calling_conv) void {
pwd_count += 1;
last_userdata = ud;
}
};
S.pwd_count = 0;
S.last_userdata = null;
var sentinel: u8 = 88;
try testing.expectEqual(Result.success, set(t, .userdata, @ptrCast(&sentinel)));
try testing.expectEqual(Result.success, set(t, .pwd_changed, @ptrCast(&S.pwdChanged)));
// OSC 7 ; file:///tmp ST — report pwd
const seq1 = "\x1B]7;file:///tmp\x1B\\";
vt_write(t, seq1, seq1.len);
try testing.expectEqual(@as(usize, 1), S.pwd_count);
try testing.expectEqual(@as(?*anyopaque, @ptrCast(&sentinel)), S.last_userdata);
try testing.expectEqualStrings("file:///tmp", zigTerminal(t).?.getPwd().?);
// Another pwd change
const seq2 = "\x1B]7;file:///home/user\x1B\\";
vt_write(t, seq2, seq2.len);
try testing.expectEqual(@as(usize, 2), S.pwd_count);
try testing.expectEqualStrings("file:///home/user", zigTerminal(t).?.getPwd().?);
}
test "pwd_changed without callback is silent" {
var t: Terminal = null;
try testing.expectEqual(Result.success, new(
&lib.alloc.test_allocator,
&t,
.{
.cols = 80,
.rows = 24,
.max_scrollback = 0,
},
));
defer free(t);
// OSC 7 without a callback should not crash, but should still set the pwd
const seq = "\x1B]7;file:///tmp\x1B\\";
vt_write(t, seq, seq.len);
try testing.expectEqualStrings("file:///tmp", zigTerminal(t).?.getPwd().?);
}
test "set size callback" {
var t: Terminal = null;
try testing.expectEqual(Result.success, new(

View File

@@ -83,6 +83,11 @@ pub const Handler = struct {
/// handler.terminal.getTitle().
title_changed: ?*const fn (*Handler) void,
/// Called when the terminal pwd changes via escape sequences
/// (e.g. OSC 7). The new pwd can be queried via
/// handler.terminal.getPwd().
pwd_changed: ?*const fn (*Handler) void,
/// Called in response to an XTVERSION query. Returns the version
/// string to report (e.g. "ghostty 1.2.3"). The returned memory
/// must be valid for the lifetime of the call. The maximum length
@@ -99,6 +104,7 @@ pub const Handler = struct {
.enquiry = null,
.size = null,
.title_changed = null,
.pwd_changed = null,
.write_pty = null,
.xtversion = null,
};
@@ -268,6 +274,7 @@ pub const Handler = struct {
.request_mode_unknown => self.requestModeUnknown(value.mode, value.ansi),
.size_report => self.reportSize(value),
.window_title => self.windowTitle(value.title),
.report_pwd => self.reportPwd(value.url),
.xtversion => self.reportXtversion(),
// No supported DCS commands have any terminal-modifying effects,
@@ -278,7 +285,6 @@ pub const Handler = struct {
=> {},
// Have no terminal-modifying effect
.report_pwd,
.show_desktop_notification,
.progress_report,
.clipboard_contents,
@@ -437,6 +443,29 @@ pub const Handler = struct {
func(self);
}
fn reportPwd(self: *Handler, url_raw: []const u8) void {
// Prevent DoS attacks by limiting url length. Headroom for
// Linux PATH_MAX (4096) plus URI scheme/host and percent-encoding.
const max_url_len = 4096;
const url = if (url_raw.len > max_url_len) url: {
log.warn("pwd url length {d} exceeds max length {d}, truncating", .{
url_raw.len,
max_url_len,
});
break :url url_raw[0..max_url_len];
} else url_raw;
// We store the raw payload unparsed. Embedders read it via
// getPwd() and are responsible for decoding any URI scheme.
self.terminal.setPwd(url) catch |err| {
log.warn("error setting pwd err={}", .{err});
return;
};
const func = self.effects.pwd_changed orelse return;
func(self);
}
fn requestMode(self: *Handler, mode: modes.Mode) void {
const report = self.terminal.modes.getReport(.fromMode(mode));
self.sendModeReport(report);